From a2f3f169306538e884c37b59223238900f910690 Mon Sep 17 00:00:00 2001 From: Michael Cassaniti Date: Wed, 7 Nov 2018 20:32:07 +1100 Subject: [PATCH] win_updates: Add post search category matching to support product matching (#45708) * win_update: Add post search category matching to support product matching * win_updates: Return categories of each update * win_updates: Documentation fix-up * win_updates: Adjusted documentation to reflect regex vs sub-string match of post-cat strings * win_updates: Sped up post-category checking * win_updates: Updated documentation to suggest querying post-category strings * win_updates: Simplified saving and checking post-categories * fixed some issues and added filtered categories to return value * win_updates: Moved all category matching to occur after initial search * win_updates: Adjustments to satisfy PowerShell lint checks * win_updates: Dropped category validation from action plugin * win_updates: Documentation updates * win_updates: Fixed plugin unit tests --- .../win_updates-post-categories.yaml | 2 + lib/ansible/modules/windows/win_updates.ps1 | 180 +++++++++--------- lib/ansible/modules/windows/win_updates.py | 36 ++-- lib/ansible/plugins/action/win_updates.py | 28 --- .../targets/win_updates/tasks/tests.yml | 8 - test/units/plugins/action/test_win_updates.py | 8 - 6 files changed, 113 insertions(+), 149 deletions(-) create mode 100644 changelogs/fragments/win_updates-post-categories.yaml diff --git a/changelogs/fragments/win_updates-post-categories.yaml b/changelogs/fragments/win_updates-post-categories.yaml new file mode 100644 index 00000000000..526d801cfdf --- /dev/null +++ b/changelogs/fragments/win_updates-post-categories.yaml @@ -0,0 +1,2 @@ +minor_changes: +- win_updates - Reworked filtering updates based on category classification - https://github.com/ansible/ansible/issues/45476 diff --git a/lib/ansible/modules/windows/win_updates.ps1 b/lib/ansible/modules/windows/win_updates.ps1 index cd4ca990b9c..b9df9d16100 100644 --- a/lib/ansible/modules/windows/win_updates.ps1 +++ b/lib/ansible/modules/windows/win_updates.ps1 @@ -17,31 +17,27 @@ $state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "insta $blacklist = Get-AnsibleParam -obj $params -name "blacklist" -type "list" $whitelist = Get-AnsibleParam -obj $params -name "whitelist" -type "list" -Function Get-CategoryGuid($category_name) { - $guid = switch -exact ($category_name) { - "Application" {"5C9376AB-8CE6-464A-B136-22113DD69801"} - "Connectors" {"434DE588-ED14-48F5-8EED-A15E09A991F6"} - "CriticalUpdates" {"E6CF1350-C01B-414D-A61F-263D14D133B4"} - "DefinitionUpdates" {"E0789628-CE08-4437-BE74-2495B842F43B"} - "DeveloperKits" {"E140075D-8433-45C3-AD87-E72345B36078"} - "FeaturePacks" {"B54E7D24-7ADD-428F-8B75-90A396FA584F"} - "Guidance" {"9511D615-35B2-47BB-927F-F73D8E9260BB"} - "SecurityUpdates" {"0FA1201D-4330-4FA8-8AE9-B877473B6441"} - "ServicePacks" {"68C5B0A3-D1A6-4553-AE49-01D3A7827828"} - "Tools" {"B4832BD8-E735-4761-8DAF-37F882276DAB"} - "UpdateRollups" {"28BC880E-0592-4CBF-8F95-C79B17911D5F"} - "Updates" {"CD5FFD1E-E932-4E3A-BF74-18BF0B1BBD83"} - default { Fail-Json -message "Unknown category_name $category_name, must be one of (Application,Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits,FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools,UpdateRollups,Updates)" } +# For backwards compatibility +Function Get-CategoryMapping ($category_name) { + switch -exact ($category_name) { + "CriticalUpdates" {return "Critical Updates"} + "DefinitionUpdates" {return "Definition Updates"} + "DeveloperKits" {return "Developer Kits"} + "FeaturePacks" {return "Feature Packs"} + "SecurityUpdates" {return "Security Updates"} + "ServicePacks" {return "Service Packs"} + "UpdateRollups" {return "Update Rollups"} + default {return $category_name} } - return $guid } -$category_guids = $category_names | ForEach-Object { Get-CategoryGuid -category_name $_ } + +$category_names = $category_names | ForEach-Object { Get-CategoryMapping -category_name $_ } $common_functions = { Function Write-DebugLog($msg) { $date_str = Get-Date -Format u $msg = "$date_str $msg" - + Write-Debug -Message $msg if ($log_path -ne $null -and (-not $check_mode)) { Add-Content -Path $log_path -Value $msg @@ -59,7 +55,7 @@ $update_script_block = { Function Start-Updates { Param( - $category_guids, + $category_names, $log_path, $state, $blacklist, @@ -71,7 +67,7 @@ $update_script_block = { updates = @{} filtered_updates = @{} } - + Write-DebugLog -msg "Creating Windows Update session..." try { $session = New-Object -ComObject Microsoft.Update.Session @@ -80,7 +76,7 @@ $update_script_block = { $result.msg = "Failed to create Microsoft.Update.Session COM object: $($_.Exception.Message)" return $result } - + Write-DebugLog -msg "Create Windows Update searcher..." try { $searcher = $session.CreateUpdateSearcher() @@ -89,24 +85,17 @@ $update_script_block = { $result.msg = "Failed to create Windows Update search from session: $($_.Exception.Message)" return $result } - - # OR is only allowed at the top-level, so we have to repeat base criteria inside - # FUTURE: change this to client-side filtered? - $criteria_base = "IsInstalled = 0" - $criteria_list = $category_guids | ForEach-Object { "($criteria_base AND CategoryIds contains '$_') " } - $criteria = [string]::Join(" OR", $criteria_list) - Write-DebugLog -msg "Search criteria: $criteria" - - Write-DebugLog -msg "Searching for updates to install in category Ids $category_guids..." + + Write-DebugLog -msg "Searching for updates to install" try { - $search_result = $searcher.Search($criteria) + $search_result = $searcher.Search("IsInstalled = 0") } catch { $result.failed = $true - $result.msg = "Failed to search for updates with criteria '$criteria': $($_.Exception.Message)" + $result.msg = "Failed to search for updates: $($_.Exception.Message)" return $result } Write-DebugLog -msg "Found $($search_result.Updates.Count) updates" - + Write-DebugLog -msg "Creating update collection..." try { $updates_to_install = New-Object -ComObject Microsoft.Update.UpdateColl @@ -115,7 +104,7 @@ $update_script_block = { $result.msg = "Failed to create update collection object: $($_.Exception.Message)" return $result } - + foreach ($update in $search_result.Updates) { $update_info = @{ title = $update.Title @@ -123,10 +112,10 @@ $update_script_block = { kb = $update.KBArticleIDs id = $update.Identity.UpdateId installed = $false + categories = ($update.Categories | ForEach-Object { $_.Name }) } - - # validate update again blacklist/whitelist - $skipped = $false + + # validate update again blacklist/whitelist/post_category_names/hidden $whitelist_match = $false foreach ($whitelist_entry in $whitelist) { if ($update_info.title -imatch $whitelist_entry) { @@ -142,33 +131,52 @@ $update_script_block = { } if ($whitelist.Length -gt 0 -and -not $whitelist_match) { Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was not found in the whitelist" - $skipped = $true + $update_info.filtered_reason = "whitelist" + $result.filtered_updates[$update_info.id] = $update_info + continue } - foreach ($kb in $update_info.kb) { - if ("KB$kb" -imatch $blacklist_entry) { - $kb_match = $true + $blacklist_match = $false + foreach ($blacklist_entry in $blacklist) { + if ($update_info.title -imatch $blacklist_entry) { + $blacklist_match = $true + break } - foreach ($blacklist_entry in $blacklist) { - $kb_match = $false - foreach ($kb in $update_info.kb) { - if ("KB$kb" -imatch $blacklist_entry) { - $kb_match = $true - } - } - if ($kb_match -or $update_info.title -imatch $blacklist_entry) { - Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was found in the blacklist" - $skipped = $true + foreach ($kb in $update_info.kb) { + if ("KB$kb" -imatch $blacklist_entry) { + $blacklist_match = $true break } } } - if ($skipped) { + if ($blacklist_match) { + Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was found in the blacklist" + $update_info.filtered_reason = "blacklist" $result.filtered_updates[$update_info.id] = $update_info continue } - - + + if ($update.IsHidden) { + Write-DebugLog -msg "Skipping update $($update_info.title) as it was hidden" + $update_info.filtered_reason = "skip_hidden" + $result.filtered_updates[$update_info.id] = $update_info + continue + } + + $category_match = $false + foreach ($match_cat in $category_names) { + if ($update_info.categories -ieq $match_cat) { + $category_match = $true + break + } + } + if ($category_names.Length -gt 0 -and -not $category_match) { + Write-DebugLog -msg "Skipping update $($update_info.id) - $($update_info.title) as it was not found in the category names filter" + $update_info.filtered_reason = "category_names" + $result.filtered_updates[$update_info.id] = $update_info + continue + } + if (-not $update.EulaAccepted) { Write-DebugLog -msg "Accepting EULA for $($update_info.id)" try { @@ -179,36 +187,31 @@ $update_script_block = { return $result } } - - if ($update.IsHidden) { - Write-DebugLog -msg "Skipping hidden update $($update_info.title)" - continue - } - + Write-DebugLog -msg "Adding update $($update_info.id) - $($update_info.title)" $updates_to_install.Add($update) > $null - + $result.updates[$update_info.id] = $update_info } - + Write-DebugLog -msg "Calculating pre-install reboot requirement..." - + # calculate this early for check mode, and to see if we should allow updates to continue $result.reboot_required = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired $result.found_update_count = $updates_to_install.Count $result.installed_update_count = 0 - + # Early exit of check mode/state=searched as it cannot do more after this if ($check_mode -or $state -eq "searched") { Write-DebugLog -msg "Check mode: exiting..." Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)" - + if ($updates_to_install.Count -gt 0 -and ($state -ne "searched")) { $result.changed = $true } return $result } - + if ($updates_to_install.Count -gt 0) { if ($result.reboot_required) { Write-DebugLog -msg "FATAL: A reboot is required before more updates can be installed" @@ -221,7 +224,7 @@ $update_script_block = { # no updates to install exit here return $result } - + Write-DebugLog -msg "Downloading updates..." $update_index = 1 foreach ($update in $updates_to_install) { @@ -231,7 +234,7 @@ $update_script_block = { $update_index++ continue } - + Write-DebugLog -msg "Creating downloader object..." try { $dl = $session.CreateUpdateDownloader() @@ -240,7 +243,7 @@ $update_script_block = { $result.msg = "Failed to create downloader object: $($_.Exception.Message)" return $result } - + Write-DebugLog -msg "Creating download collection..." try { $dl.Updates = New-Object -ComObject Microsoft.Update.UpdateColl @@ -249,10 +252,10 @@ $update_script_block = { $result.msg = "Failed to create download collection object: $($_.Exception.Message)" return $result } - + Write-DebugLog -msg "Adding update $update_number $($update.Identity.UpdateId)" $dl.Updates.Add($update) > $null - + Write-DebugLog -msg "Downloading $update_number $($update.Identity.UpdateId)" try { $download_result = $dl.Download() @@ -261,7 +264,7 @@ $update_script_block = { $result.msg = "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): $($_.Exception.Message)" return $result } - + Write-DebugLog -msg "Download result code for $update_number $($update.Identity.UpdateId) = $($download_result.ResultCode)" # FUTURE: configurable download retry if ($download_result.ResultCode -ne 2) { # OperationResultCode orcSucceeded @@ -269,14 +272,14 @@ $update_script_block = { $result.msg = "Failed to download update $update_number $($update.Identity.UpdateId) - $($update.Title): Download Result $($download_result.ResultCode)" return $result } - + $result.changed = $true $update_index++ } - + Write-DebugLog -msg "Installing updates..." - # install as a batch so the reboot manager will suppress intermediate reboots + Write-DebugLog -msg "Creating installer object..." try { $installer = $session.CreateUpdateInstaller() @@ -285,7 +288,7 @@ $update_script_block = { $result.msg = "Failed to create Update Installer object: $($_.Exception.Message)" return $result } - + Write-DebugLog -msg "Creating install collection..." try { $installer.Updates = New-Object -ComObject Microsoft.Update.UpdateColl @@ -294,12 +297,12 @@ $update_script_block = { $result.msg = "Failed to create Update Collection object: $($_.Exception.Message)" return $result } - + foreach ($update in $updates_to_install) { Write-DebugLog -msg "Adding update $($update.Identity.UpdateID)" $installer.Updates.Add($update) > $null } - + # FUTURE: use BeginInstall w/ progress reporting so we can at least log intermediate install results try { $install_result = $installer.Install() @@ -308,10 +311,10 @@ $update_script_block = { $result.msg = "Failed to install update from Update Collection: $($_.Exception.Message)" return $result } - + $update_success_count = 0 $update_fail_count = 0 - + # WU result API requires us to index in to get the install results $update_index = 0 foreach ($update in $updates_to_install) { @@ -325,9 +328,9 @@ $update_script_block = { } $update_resultcode = $update_result.ResultCode $update_hresult = $update_result.HResult - + $update_index++ - + $update_dict = $result.updates[$update.Identity.UpdateID] if ($update_resultcode -eq 2) { # OperationResultCode orcSucceeded $update_success_count++ @@ -341,18 +344,18 @@ $update_script_block = { Write-DebugLog -msg "Update $update_number $($update.Identity.UpdateID) failed, resultcode: $update_resultcode, hresult: $update_hresult" } } - + Write-DebugLog -msg "Performing post-install reboot requirement check..." $result.reboot_required = (New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired $result.installed_update_count = $update_success_count $result.failed_update_count = $update_fail_count - + if ($update_fail_count -gt 0) { $result.failed = $true $result.msg = "Failed to install one or more updates" return $result } - + Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)" return $result @@ -389,7 +392,7 @@ Function Start-Natively($common_functions, $script) { # add the update script block and required parameters $ps_pipeline.AddStatement().AddScript($script) > $null $ps_pipeline.AddParameter("arguments", @{ - category_guids = $category_guids + category_names = $category_names log_path = $log_path state = $state blacklist = $blacklist @@ -450,7 +453,7 @@ Function Start-AsScheduledTask($common_functions, $script) { Name = $job_name ArgumentList = @( @{ - category_guids = $category_guids + category_names = $category_names log_path = $log_path state = $state blacklist = $blacklist @@ -499,7 +502,7 @@ Function Start-AsScheduledTask($common_functions, $script) { Write-DebugLog -msg "Waiting for job output to populate..." Start-Sleep -Milliseconds 500 } - + # NB: fallthru on both timeout and success $ret = @{ ErrorOutput = $job.Error @@ -553,3 +556,4 @@ if ($wua_available) { } Exit-Json -obj $result + diff --git a/lib/ansible/modules/windows/win_updates.py b/lib/ansible/modules/windows/win_updates.py index db20ea88c4b..c2ec445f49a 100644 --- a/lib/ansible/modules/windows/win_updates.py +++ b/lib/ansible/modules/windows/win_updates.py @@ -33,22 +33,14 @@ options: version_added: '2.5' category_names: description: - - A scalar or list of categories to install updates from + - A scalar or list of categories to install updates from. To get the list + of categories, run the module with C(state=searched). The category must + be the full category string, but is case insensitive. + - Some possible categories are Application, Connectors, Critical Updates, + Definition Updates, Developer Kits, Feature Packs, Guidance, Security + Updates, Service Packs, Tools, Update Rollups and Updates. type: list default: [ CriticalUpdates, SecurityUpdates, UpdateRollups ] - choices: - - Application - - Connectors - - CriticalUpdates - - DefinitionUpdates - - DeveloperKits - - FeaturePacks - - Guidance - - SecurityUpdates - - ServicePacks - - Tools - - UpdateRollups - - Updates reboot: description: - Ansible will automatically reboot the remote host if it is required @@ -191,6 +183,11 @@ updates: returned: always type: boolean sample: True + categories: + description: A list of category strings for this update + returned: always + type: list of strings + sample: [ 'Critical Updates', 'Windows Server 2012 R2' ] failure_hresult_code: description: The HRESULT code from a failed update returned: on install failure @@ -199,12 +196,17 @@ updates: filtered_updates: description: List of updates that were found but were filtered based on - I(blacklist) or I(whitelist). The return value is in the same form as - I(updates). + I(blacklist), I(whitelist) or I(category_names). The return value is in + the same form as I(updates), along with I(filtered_reason). returned: success type: complex sample: see the updates return value - contains: {} + contains: + filtered_reason: + description: The reason why this update was filtered + returned: always + type: string + sample: 'skip_hidden' found_update_count: description: The number of updates found needing to be applied diff --git a/lib/ansible/plugins/action/win_updates.py b/lib/ansible/plugins/action/win_updates.py index 2d8adc44b52..70f22629ccf 100644 --- a/lib/ansible/plugins/action/win_updates.py +++ b/lib/ansible/plugins/action/win_updates.py @@ -20,26 +20,6 @@ class ActionModule(ActionBase): DEFAULT_REBOOT_TIMEOUT = 1200 - def _validate_categories(self, category_names): - valid_categories = [ - 'Application', - 'Connectors', - 'CriticalUpdates', - 'DefinitionUpdates', - 'DeveloperKits', - 'FeaturePacks', - 'Guidance', - 'SecurityUpdates', - 'ServicePacks', - 'Tools', - 'UpdateRollups', - 'Updates' - ] - for name in category_names: - if name not in valid_categories: - raise AnsibleError("Unknown category_name %s, must be one of " - "(%s)" % (name, ','.join(valid_categories))) - def _run_win_updates(self, module_args, task_vars, use_task): display.vvv("win_updates: running win_updates module") wrap_async = self._task.async_val @@ -172,14 +152,6 @@ class ActionModule(ActionBase): use_task = boolean(self._task.args.get('use_scheduled_task', False), strict=False) - # Validate the options - try: - self._validate_categories(category_names) - except AnsibleError as exc: - result['failed'] = True - result['msg'] = to_text(exc) - return result - if state not in ['installed', 'searched']: result['failed'] = True result['msg'] = "state must be either installed or searched" diff --git a/test/integration/targets/win_updates/tasks/tests.yml b/test/integration/targets/win_updates/tasks/tests.yml index add3dc36c62..950f5bcc6bb 100644 --- a/test/integration/targets/win_updates/tasks/tests.yml +++ b/test/integration/targets/win_updates/tasks/tests.yml @@ -5,14 +5,6 @@ register: invalid_state failed_when: invalid_state.msg != 'state must be either installed or searched' -- name: expect failure with invalid category name - win_updates: - state: searched - category_names: - - Invalid - register: invalid_category_name - failed_when: invalid_category_name.msg != 'Unknown category_name Invalid, must be one of (Application,Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits,FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools,UpdateRollups,Updates)' - - name: ensure log file not present before tests win_file: path: '{{win_updates_dir}}/update.log' diff --git a/test/units/plugins/action/test_win_updates.py b/test/units/plugins/action/test_win_updates.py index 80b80178791..1eb9baddb5e 100644 --- a/test/units/plugins/action/test_win_updates.py +++ b/test/units/plugins/action/test_win_updates.py @@ -16,14 +16,6 @@ from ansible.playbook.task import Task class TestWinUpdatesActionPlugin(object): INVALID_OPTIONS = ( - ( - {"category_names": ["fake category"]}, - False, - "Unknown category_name fake category, must be one of (Application," - "Connectors,CriticalUpdates,DefinitionUpdates,DeveloperKits," - "FeaturePacks,Guidance,SecurityUpdates,ServicePacks,Tools," - "UpdateRollups,Updates)" - ), ( {"state": "invalid"}, False,