The scripts explained in this post allow you to automate several Windows Server Update Services (WSUS) tasks such as synchronization, approvals, cleanups, and scheduled update installations.
Avatar

Note that I didn't write all the scripts myself. I will provide link to the original sources where this is the case.

Syncing WSUS with PowerShell and Task Scheduler

In this article, I assume you are familiar with WSUS administration. Right after installing WSUS, you have to configure periodic synchronization. Unfortunately, as you can see in the screenshot below, the synchronization options are somewhat limited.

WSUS synchronization options

WSUS synchronization options

Since I don't need to sync every day, I select Synchronize manually and use the script below along with Task Scheduler to synchronize WSUS at the times I prefer.

$wsusserver = "wsus"
[reflection.assembly]::LoadWithPartialName("Microsoft.UpdateServices.Administration") | out-null
$wsus = [Microsoft.UpdateServices.Administration.AdminProxy]::GetUpdateServer($wsusserver, $False,8530);
$wsus.GetSubscription().StartSynchronization();

I load the .NET Update Services object into the $wsusserver variable and use the StartSynchronization() method to start manual synchronization. The screenshot below shows the Task Scheduler task I'm using to launch the PowerShell script.

WSUS synchronization task

WSUS synchronization task

You will see the synchronization results in the WSUS console as if you synced manually:

Synchronization results

Synchronization results

Automating WSUS update approval

The next task I'm going to automate is the approval of updates. WSUS offers automatic approval. However, it is quite inflexible, so I wrote the PowerShell script below:

[string[]]$recipients = admins@contoso.com #Email address where you want to send the notification after the script completes

$wsusserver = "wsus" #WSUS server name

$log = "C:\Temp\Approved_Updates_{0:MMddyyyy_HHmm}.log" -f (Get-Date) #Log file name

new-item -path $log -type file -force #Creating log file

[void][reflection.assembly]::LoadWithPartialName ("Microsoft.UpdateServices.Administration") #Loading the WSUS .NET classes

$wsus = [Microsoft.UpdateServices.Administration.AdminProxy]::getUpdateServer($wsusserver, $False,8530) #Storing the object into the variable

$UpdateScope = New-Object Microsoft.UpdateServices.Administration.UpdateScope #Loading WSUS Update scope object into variable

$groups = "All Computers" #Setting up groups for updates approval

$Classification = $wsus.GetUpdateClassifications() | ?{$_.Title -ne 'Service Packs' ‑and $_.Title -ne 'Drivers' -and $_.Title -ne 'Upgrades'} #Setting up update classifications for approval

$Categories = $wsus.GetUpdateCategories() | ? {$_.Title -notmatch "SQL" -and $_.Title -notmatch "Skype"} #Setting up update categories for approval

$UpdateScope.FromCreationDate = (get-date).AddMonths(-1) #Configuring starting date for UpdateScope interval

$UpdateScope.ToCreationDate = (get-date) #Configuring ending date for UpdateScope interval

$UpdateScope.Classifications.Clear() #Clearing classification object before assigning new value to it

$UpdateScope.Classifications.AddRange($Classification) #Assigning previously prepared classifications to the classification object

$UpdateScope.Categories.Clear() #Clearing the categories object before assigning a new value to it

$UpdateScope.Categories.AddRange($Categories) #Assigning previously prepared categories to the classification object

$updates = $wsus.GetUpdates($UpdateScope) | ? {($_.Title -notmatch "LanguageInterfacePack" -and $_.Title -notmatch "LanguagePack" -and $_.Title -notmatch "FeatureOnDemand" -and $_.Title -notmatch "Skype" -and $_.Title -notmatch "SQL" -and $_.Title -notmatch "Itanium" -and $_.PublicationState -ne "Expired" -and $_.IsDeclined -eq $False )} #Storing all updates in the previously defined UpdateScope interval to the $updates variable and filtering out those not required

foreach ($group in $groups) #Looping through groups
  {
   $wgroup = $wsus.GetComputerTargetGroups() | where {$_.Name -eq $group} #Storing the current group into the $wgroup variable
   foreach ($update in $updates) #Looping through updates
     {
      $update.Approve(“Install”,$wgroup) #Approving each update for the current group
     }
  }

$date = Get-Date #Storing the current date into the $date variable

"Aproved updates (on " + $date + "): " | Out-File $log -append #Updating the log file

"Updates have been approved for following groups: (" + $groups + ")" | Out-File $log ‑append #Updating log file

"Folowing updates have been approved:" | Out-File $log -append #Updating the log file

$updates | Select Title,ProductTitles,KnowledgebaseArticles,CreationDate | ft -Wrap | Out-File $log -append #Updating log file

Send-MailMessage -From "WSUS@contoso.com" -To $recipients -Subject "New updates have been approved" -Body "Please find the list of approved updates enclosed" -Attachments $log -SmtpServer "smtp-server" -DeliveryNotificationOption OnFailure #Sending the log file by email.

I added comments, so I'll just explain briefly how the script works. First I load the Windows Update Assembly, so I can use the WSUS .NET object. Then I'm preparing the variables that I need to work with the WSUS object:

  • $wsus: is the WSUS object.
  • $UpdateScope: Defines the time interval for the $wsus.GetUpdates() method.
  • $groups: Defines all WSUS groups I'd like to approve updates for.
  • $Classification: Defines updates classifications for the $wsus.GetUpdates() method. I'm filtering out service packs, drivers, and upgrades.
  • $Categories: Defines updates categories or products for the $wsus.GetUpdates() method. I'm filtering out SQL and Skype updates. SQL gets updated manually, and I don't have Skype installations in my environment.

Then I'm setting up the Update Scope interval to get only updates created within the last month. I know I'm approving my updates every month, so I only need to get recently released updates.

After that, I'm assigning the $Classification and $Categories variables to the corresponding objects. And with the help of the $wsus.GetUpdates($UpdateScope) method, I am saving all updates that match my scope to the $updates variable. Then I'm adding some filtering to remove updates such as LanguagePack, FeatureOnDemand, and Itanium from the results because I don't have these kinds of updates in my environment.

Now I have all updates I want to approve. Next, I'm looping through the WSUS groups to which I want to assign the updates. Then I loop through the updates, approving every update for every group. In this particular case, there is only one group. However, I use a loop here, just to be able to add more groups later.

After approving all updates, I only need to update the log file and send this file by email to myself. This way, I am sure I've approved the updates, and I receive brief information about them.

Like before, I'm using Task Scheduler to run the script:

WSUS updates approval task

WSUS updates approval task

Declining superseded updates

As you know, Microsoft frequently replaces single updates with packages of multiple updates. They call the replaced update a "superseded update," which is no longer needed. Thus, it makes sense to decline those updates. For this purpose, I modified the PowerShell script below, which I found here.

My changes are in the lines 57–59, 99–100, and 242. I added the transcript file, so when the script ran via the Task Scheduler, I could see the number of declined updates. And after I ran the script the first time, I changed the update scope. So it'll check and decline only updates within the last six months.

# ===============================================
# Script to decline superseeded updates in WSUS.
# ===============================================
# It's recommended to run the script with the -SkipDecline switch to see how many superseded updates are in WSUS and to TAKE A BACKUP OF THE SUSDB before declining the updates.
# Parameters:

# $UpdateServer             = Specify WSUS Server Name
# $UseSSL                   = Specify whether WSUS Server is configured to use SSL
# $Port                     = Specify WSUS Server Port
# $SkipDecline              = Specify this to do a test run and get a summary of how many superseded updates we have
# $DeclineLastLevelOnly     = Specify whether to decline all superseded updates or only last level superseded updates
# $ExclusionPeriod          = Specify the number of days between today and the release date for which the superseded updates must not be declined. Eg, if you want to keep superseded updates published within the last 2 months, specify a value of 60 (days)


# Supersedence chain could have multiple updates. 
# For example, Update1 supersedes Update2. Update2 supersedes Update3. In this scenario, the Last Level in the supersedence chain is Update3. 
# To decline only the last level updates in the supersedence chain, specify the DeclineLastLevelOnly switch

# Usage:
# =======

# To do a test run against WSUS Server without SSL
# Decline-SupersededUpdates.ps1 -UpdateServer SERVERNAME -Port 8530 -SkipDecline

# To do a test run against WSUS Server using SSL
# Decline-SupersededUpdates.ps1 -UpdateServer SERVERNAME -UseSSL -Port 8531 -SkipDecline

# To decline all superseded updates on the WSUS Server using SSL
# Decline-SupersededUpdates.ps1 -UpdateServer SERVERNAME -UseSSL -Port 8531

# To decline only Last Level superseded updates on the WSUS Server using SSL
# Decline-SupersededUpdates.ps1 -UpdateServer SERVERNAME -UseSSL -Port 8531 -DeclineLastLevelOnly

# To decline all superseded updates on the WSUS Server using SSL but keep superseded updates published within the last 2 months (60 days)
# Decline-SupersededUpdates.ps1 -UpdateServer SERVERNAME -UseSSL -Port 8531 -ExclusionPeriod 60


[CmdletBinding()]
Param(
    [Parameter(Mandatory=$True,Position=1)]
    [string] $UpdateServer,
    
    [Parameter(Mandatory=$False)]
    [switch] $UseSSL,
    
    [Parameter(Mandatory=$True, Position=2)]
    $Port,
    
    [switch] $SkipDecline,
    
    [switch] $DeclineLastLevelOnly,
    
    [Parameter(Mandatory=$False)]
    [int] $ExclusionPeriod = 0
)

$file = "c:\temp\WSUS_Decline_Superseded_{0:MMddyyyy_HHmm}.log" -f (Get-Date) 

Start-Transcript -Path $file


if ($SkipDecline -and $DeclineLastLevelOnly) {
    Write-Output "Using SkipDecline and DeclineLastLevelOnly switches together is not allowed."
    Write-Output ""
    return
}

$outPath = Split-Path $script:MyInvocation.MyCommand.Path
$outSupersededList = Join-Path $outPath "SupersededUpdates.csv"
$outSupersededListBackup = Join-Path $outPath "SupersededUpdatesBackup.csv"
"UpdateID, RevisionNumber, Title, KBArticle, SecurityBulletin, LastLevel" | Out-File $outSupersededList

try {
    
    if ($UseSSL) {
        Write-Output "Connecting to WSUS server $UpdateServer on Port $Port using SSL... " -NoNewLine
    } Else {
        Write-Output "Connecting to WSUS server $UpdateServer on Port $Port... " -NoNewLine
    }
    
    [reflection.assembly]::LoadWithPartialName("Microsoft.UpdateServices.Administration") | out-null
    $wsus = [Microsoft.UpdateServices.Administration.AdminProxy]::GetUpdateServer($UpdateServer, $UseSSL, $Port);
}
catch [System.Exception] 
{
    Write-Output "Failed to connect."
    Write-Output "Error:" $_.Exception.Message
    Write-Output "Please make sure that WSUS Admin Console is installed on this machine"
    Write-Output ""
    $wsus = $null
}

if ($wsus -eq $null) { return } 

Write-Output "Connected."

$UpdateScope = New-Object Microsoft.UpdateServices.Administration.UpdateScope

(get-date).AddMonths(-6)
$UpdateScope.FromArrivalDate = (get-date).AddMonths(-6)
$UpdateScope.ToArrivalDate = (get-date)

$countAllUpdates = 0
$countSupersededAll = 0
$countSupersededLastLevel = 0
$countSupersededExclusionPeriod = 0
$countSupersededLastLevelExclusionPeriod = 0
$countDeclined = 0

Write-Output "Getting a list of all updates... " -NoNewLine

try {
    $allUpdates = $wsus.GetUpdates($UpdateScope)
}

catch [System.Exception]
{
    Write-Output "Failed to get updates."
    Write-Output "Error:" $_.Exception.Message
    Write-Output "If this operation timed out, please decline the superseded updates from the WSUS Console manually."
    Write-Output ""
    return
}

Write-Output "Done"

Write-Output "Parsing the list of updates... " -NoNewLine
foreach($update in $allUpdates) {
    
    $countAllUpdates++
    
    if ($update.IsDeclined) {
        $countDeclined++
    }
    
    if (!$update.IsDeclined -and $update.IsSuperseded) {
        $countSupersededAll++
        
        if (!$update.HasSupersededUpdates) {
            $countSupersededLastLevel++
        }

        if ($update.CreationDate -lt (get-date).AddDays(-$ExclusionPeriod))  {
            $countSupersededExclusionPeriod++
            if (!$update.HasSupersededUpdates) {
                $countSupersededLastLevelExclusionPeriod++
            }
        }        
        
        "$($update.Id.UpdateId.Guid), $($update.Id.RevisionNumber), $($update.Title), $($update.KnowledgeBaseArticles), $($update.SecurityBulletins), $($update.HasSupersededUpdates)" | Out-File $outSupersededList -Append       
        
    }
}

Write-Output "Done."
Write-Output "List of superseded updates: $outSupersededList"

Write-Output ""
Write-Output "Summary:"
Write-Output "========"

Write-Output "All Updates = $countAllUpdates"
$AnyExceptDeclined = $countAllUpdates - $countDeclined
Write-Output "Any except Declined = $AnyExceptDeclined"
Write-Output "All Superseded Updates = $countSupersededAll"
$SuperseededAllOutput = $countSupersededAll - $countSupersededLastLevel
Write-Output "    Superseded Updates (Intermediate) = $SuperseededAllOutput"
Write-Output "    Superseded Updates (Last Level) = $countSupersededLastLevel"
Write-Output "    Superseded Updates (Older than $ExclusionPeriod days) = $countSupersededExclusionPeriod"
Write-Output "    Superseded Updates (Last Level Older than $ExclusionPeriod days) = $countSupersededLastLevelExclusionPeriod"

$i = 0
if (!$SkipDecline) {
    
    Write-Output "SkipDecline flag is set to $SkipDecline. Continuing with declining updates"
    $updatesDeclined = 0
    
    if ($DeclineLastLevelOnly) {
        Write-Output "  DeclineLastLevel is set to True. Only declining last level superseded updates." 
        
        foreach ($update in $allUpdates) {
            
            if (!$update.IsDeclined -and $update.IsSuperseded -and !$update.HasSupersededUpdates) {
              if ($update.CreationDate -lt (get-date).AddDays(-$ExclusionPeriod))  {
                $i++
                $percentComplete = "{0:N2}" -f (($updatesDeclined/$countSupersededLastLevelExclusionPeriod) * 100)
                Write-Progress -Activity "Declining Updates" -Status "Declining update #$i/$countSupersededLastLevelExclusionPeriod - $($update.Id.UpdateId.Guid)" -PercentComplete $percentComplete -CurrentOperation "$($percentComplete)% complete"
                
                try 
                {
                    $update.Decline()                    
                    $updatesDeclined++
                }
                catch [System.Exception]
                {
                    Write-Output "Failed to decline update $($update.Id.UpdateId.Guid). Error:" $_.Exception.Message
                } 
              }             
            }
        }        
    }
    else {
        Write-Output "  DeclineLastLevel is set to False. Declining all superseded updates."
        
        foreach ($update in $allUpdates) {
            
            if (!$update.IsDeclined -and $update.IsSuperseded) {
              if ($update.CreationDate -lt (get-date).AddDays(-$ExclusionPeriod))  {   
                  
                $i++
                $percentComplete = "{0:N2}" -f (($updatesDeclined/$countSupersededAll) * 100)
                Write-Progress -Activity "Declining Updates" -Status "Declining update #$i/$countSupersededAll - $($update.Id.UpdateId.Guid)" -PercentComplete $percentComplete -CurrentOperation "$($percentComplete)% complete"
                try 
                {
                    $update.Decline()
                    $updatesDeclined++
                }
                catch [System.Exception]
                {
                    Write-Output "Failed to decline update $($update.Id.UpdateId.Guid). Error:" $_.Exception.Message
                }
              }              
            }
        }   
        
    }
    
    Write-Output "  Declined $updatesDeclined updates."
    if ($updatesDeclined -ne 0) {
        Copy-Item -Path $outSupersededList -Destination $outSupersededListBackup -Force
        Write-Output "  Backed up list of superseded updates to $outSupersededListBackup"
    }
    
}
else {
    Write-Output "SkipDecline flag is set to $SkipDecline. Skipped declining updates"
}

Write-Output ""
Write-Output "Done"
Write-Output ""

Stop-Transcript

The screenshot below shows a sample log file:

Declining superseded updates log file

Declining superseded updates log file

Deleting declined updates from the WSUS database

After you decline the updates, they are still residing inside the WSUS database and taking up disk space. To remove them completely, you have to run the WSUS cleanup wizard. This is another task you can automate:

$file = "c:\temp\WSUS_CleanUp_Wiz_{0:MMddyyyy_HHmm}.log" -f (Get-Date)
Start-Transcript -Path $file
$wsusserver = "wsus"
[reflection.assembly]::LoadWithPartialName("Microsoft.UpdateServices.Administration")` | out-null
$wsus = [Microsoft.UpdateServices.Administration.AdminProxy]::GetUpdateServer($wsusserver, $False,8530);
$cleanupScope = new-object Microsoft.UpdateServices.Administration.CleanupScope;
$cleanupScope.DeclineSupersededUpdates    = $true
$cleanupScope.DeclineExpiredUpdates       = $true
$cleanupScope.CleanupObsoleteUpdates      = $true
$cleanupScope.CompressUpdates             = $false
$cleanupScope.CleanupObsoleteComputers    = $true
$cleanupScope.CleanupUnneededContentFiles = $true
$cleanupManager = $wsus.GetCleanupManager();
$cleanupManager.PerformCleanup($cleanupScope);
Stop-Transcript

All I'm doing in the script above is defining a cleanup scope using the CleanUpScope object and then running CleanUpManager using the corresponding object against that scope. I'm not compressing updates because this operation takes a long time and doesn't save much space.

The script also runs as scheduled task and produces the log file you can see below:

Cleanup wizard log file

Cleanup wizard log file

Because all of these procedures are making many changes in the WSUS database, it is good idea to re-index the database occasionally. To do that, I'm using this SQL query from the Microsoft Script Center. You can use the sqlcmd utility you find there to run the SQL query. Just create a scheduled task and run it once a month.

Arranging the maintenance scripts

Here is how I scheduled the maintenance scripts:

  1. Synchronize WSUS every Tuesday.
  2. Decline superseded updates after every WSUS synchronization.
  3. Run the WSUS cleanup wizard script after declining superseded updates finishes.
  4. Re-index the WSUS database after WSUS cleanup.
  5. Approve updates every Wednesday. This way I know I'm approving updates after removal of all superseded, outdated, and expired updates.

Scheduling updates

At this point I'm done with maintenance. However, I still need to install the updates. Unfortunately, WSUS also only offers poor choices when it comes to scheduling update installations. Basically, I can only pick the day of the week and the time. Of course, this is not always what you want. Because I have several environments, I created a Group Policy Object (GPO) for each of them and assigned them to the appropriate Active Directory organizational units (OUs).

GPO for updates installation

GPO for updates installation

As you can see, I configured this GPO to install updates every Friday at 7 p.m. The thing is, I just need to do this on a particular Friday every month. Thus, I wrote a tiny script for enabling this GPO and a second one for disabling it. Then I configured a scheduled task to run the first script a couple days before the update day and the second one after installing the updates.

Subscribe to 4sysops newsletter!

Enabling GPO
$GPO = Get-GPO -Name "WSUS DEV OU - Automatic Updates"
$GPO.GpoStatus = "AllSettingsEnabled"

Disabling GPO
$GPO = Get-GPO -Name "WSUS DEV OU - Automatic Updates"
$GPO.GpoStatus = "AllSettingsDisabled"

Conclusion

And after I've completed my configuration, I'm just checking my WSUS server every once in a while to make sure everything works as intended.

avataravataravataravatar
9 Comments
  1. Avatar
    Martin 6 years ago

    hi,
    this script is amazing but i have question …. how clean “Locally Published Updates” ?

  2. Avatar
    Reuben 5 years ago

    Thank you for these.
    Very concise article.

    Has anyone tested on Powershell v1.0?

    Should these Scripts run as they are on Powershell v1.0, or will they need re-writing, or any additional Modules installing to make them work?

    Many thanks in advance.

  3. Avatar
    Frank H. 5 years ago

    Hello Alex, so I think I finally found a script.
    I have only one question before this script becomes really useful to me.
    Section: "Automating WSUS update approval". It's about line 33 "$ updates = …", how does it work?
    The rejected updates from previous months should not be downloaded again and I would like to download only specific products, but do not enter the entire list of products with "-notmatch". That will be infinite.
    For example, my WSUS should only download Windows 10, Windows Server 2016, and 2019.
    In addition, I have a script that denies me specific subproducts before downloading the actual updates, which are then not downloaded. These subproducts are old Windows 10 versions (1517 – 1703) and everything else that W10 32Bit (x86) refuses to save space and because we have everything on 64Bit.
    Can you answer me or write privately?
    Unfortunately, I am a beginner in PS1, but the WSUS GUI is so limited with special settings.

  4. Avatar
    Gazy Smith 4 years ago

    Would you please advise what field I need to change on Declining superseded updates script?

    Does the script only removed superseded updates?

    I get an error when I removed the # I get the error on [CmdletBinding()]. 

     

  5. Avatar
    OU 3 years ago

    Thanks man this was some great write up on all stuff that needs to be done for WSUS to work properly!

  6. Avatar
    cc 2 years ago

    Getting error,, in AND operation. No glue how to resolve. Kindly help.

    PS C:\Users\me\Desktop> $Classification = $wsus.GetUpdateClassifications() | ? {$_.Title -notmatch "Service Packs"
     -And $_.Title -notmatch "Drivers" -And $_.Title -notmatch "Upgrades"}
    At line:1 char:92
    + … Service Packs" -And $_.Title -notmatch "Drivers" -And $_.Title -notmatch "Upgrad …
    +                    ~~~~
    Unexpected token '-And' in expression or statement.
        + CategoryInfo          : ParserError: (:) [], ParentContainsErrorRecordException
        + FullyQualifiedErrorId : UnexpectedToken

    avatar
    • Avatar
      Leos Marek (Rank 4) 2 years ago

      Actuall there is a typo already at line 9 – there is extra space after Loadwithpartial name. It should be

      [void][reflection.assembly]::LoadWithPartialName("Microsoft.UpdateServices.Administration")

      For the -and issue. I have the same thing when copying it from the editor. Just manuall delete the -and after Service packs and type it again. That solves it.

      @Michael can you fix the line 9?

      Thanks

  7. Avatar
    yoyo 12 months ago

    Hi,
    When i try to execute the first script, the WSUS console doesn’t show the latest scan i did,
    Should i only select the .psi file on the “Action” field of task manager ?

    • Avatar
      yoyo 12 months ago

      I mean, the .ps1 file
      I pasted the ssame code as written
      When i launch the program, it does that (i translated the part between quotes) :

      Exception (when calling) « GetUpdateServer » (with) « 3 » argument(s) : « (The distant name cannot be resolved) : ‘wsus’ »
      At C:\Script\syncWSUS.ps1:3 : 1
      + $wsus = [Microsoft.UpdateServices.Administration.AdminProxy]::GetUpda …
      + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      + CategoryInfo : NotSpecified: (:) [], MethodInvocationException
      + FullyQualifiedErrorId : WebException

      (Impossible to call a method in an expression) Null.
      At C:\Script\syncWSUS.ps1:4 : 1
      + $wsus.GetSubscription().StartSynchronization();
      + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
      + CategoryInfo : InvalidOperation : (:) [], RuntimeException
      + FullyQualifiedErrorId : InvokeMethodOnNull

Leave a reply

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

*

© 4sysops 2006 - 2023

CONTACT US

Please ask IT administration questions in the forums. Any other messages are welcome.

Sending

Log in with your credentials

or    

Forgot your details?

Create Account