Repository: ansible/ansible
Pull request: win_updates: Add post search category matching to support product matching
##### SUMMARY
Resolves #45476. Please see this issue for further details and motivation.

##### ISSUE TYPE
- Feature Pull Request

##### COMPONENT NAME
win_updates

##### ANSIBLE VERSION
```
ansible 2.8.0.dev0
  config file = /root/ansible.cfg
  configured module search path = [u'/root/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/site-packages/ansible-2.8.0.dev0-py2.7.egg/ansible
  executable location = /usr/bin/ansible
  python version = 2.7.15 (default, Aug 16 2018, 14:17:09) [GCC 6.4.0]
```

Problem statement:
win_updates post search categories (restrict which products to update)
##### SUMMARY
It is currently not possible to restrict the products that get updated with Windows using the current win_updates module. This prevents updating only the OS but not other Microsoft products using this module.

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME
win_updates

##### ADDITIONAL INFORMATION
Currently the module performs a search using fixed GUIDs for categories as part of searching for updates. Post filtering of these updates is required to limit the products that can be targeted. Each update has a list of categories that are relevant to it, and this list also contains relevant products.

The module could be extended to provide a free-form list of categories to filter on after searching. An update would need to match at least one of these categories in order to be applied.

For example (in PowerShell):
```powershell
# post_categories is user supplied and is a list of strings
# update is a single update that is being checked
$cat_matched = $False
ForEach ($update_cat in $update.Categories) {
  ForEach ($match_cat in $post_categories) {
    If ($update_cat.Name -Match $match_cat) {
      $cat_matched = $True
  }
}
```

Module usage:
```yaml
win_updates:
  category_names:
    - SecurityUpdates
    - CriticalUpdates
  post_categories:
    - Windows
  state: installed
```

Hints from issue discussion:
If there's interest I'm happy to provide a PR.
cc @dagwieers @jborean93 @jhawkesworth @nitzmahone
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
I'm not against this, we may need to have a broader discussion around the interface to specify these products but happy to see a PR and we can go from there.

Commit message: win_updates: Documentation fix-up

Patch to review:
diff --git a/lib/ansible/modules/windows/win_updates.ps1 b/lib/ansible/modules/windows/win_updates.ps1
index cd4ca990b9c59e..72c5efc8e40210 100644
--- a/lib/ansible/modules/windows/win_updates.ps1
+++ b/lib/ansible/modules/windows/win_updates.ps1
@@ -12,6 +12,7 @@ $params = Parse-Args -arguments $args -supports_check_mode $true
 $check_mode = Get-AnsibleParam -obj $params -name "_ansible_check_mode" -type "bool" -default $false
 
 $category_names = Get-AnsibleParam -obj $params -name "category_names" -type "list" -default @("CriticalUpdates", "SecurityUpdates", "UpdateRollups")
+$post_categories = Get-AnsibleParam -obj $params -name "post_category_names" -type "list" -default @()
 $log_path = Get-AnsibleParam -obj $params -name "log_path" -type "path"
 $state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "installed" -validateset "installed", "searched"
 $blacklist = Get-AnsibleParam -obj $params -name "blacklist" -type "list"
@@ -60,6 +61,7 @@ $update_script_block = {
     Function Start-Updates {
         Param(
             $category_guids,
+            [string[]]$post_categories=@(),
             $log_path,
             $state,
             $blacklist,
@@ -123,6 +125,7 @@ $update_script_block = {
                 kb = $update.KBArticleIDs
                 id = $update.Identity.UpdateId
                 installed = $false
+                categories = New-Object System.Collections.ArrayList
             }
         
             # validate update again blacklist/whitelist
@@ -184,11 +187,28 @@ $update_script_block = {
                 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
+
+            $post_cat_matched = $False
+            If ($post_categories.Count -eq 0) {
+                # There are no additional categories to check
+                $post_cat_matched = $True
+            }
+
+            ForEach ($update_cat in $update.Categories) {
+                $update_info.categories.Add($update_cat.Name) | Out-Null
+                ForEach ($match_cat in $post_categories) {
+                    If ($update_cat.Name -Match $match_cat) {
+                        $post_cat_matched = $True
+                    }
+                }
+            }
+
+            If ($post_cat_matched) {
+                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..."
@@ -390,6 +410,7 @@ Function Start-Natively($common_functions, $script) {
         $ps_pipeline.AddStatement().AddScript($script) > $null
         $ps_pipeline.AddParameter("arguments", @{
             category_guids = $category_guids
+            post_categories = $post_categories
             log_path = $log_path
             state = $state
             blacklist = $blacklist
@@ -451,6 +472,7 @@ Function Start-AsScheduledTask($common_functions, $script) {
         ArgumentList = @(
             @{
                 category_guids = $category_guids
+                post_categories = $post_categories
                 log_path = $log_path
                 state = $state
                 blacklist = $blacklist
diff --git a/lib/ansible/modules/windows/win_updates.py b/lib/ansible/modules/windows/win_updates.py
index 0feb07cfc71fe7..90c8017948ec14 100644
--- a/lib/ansible/modules/windows/win_updates.py
+++ b/lib/ansible/modules/windows/win_updates.py
@@ -49,6 +49,18 @@
         - Tools
         - UpdateRollups
         - Updates
+    post_category_names:
+        description:
+        - A list of categories to check an update is in. This can be used to
+          confirm which product categories are applied. Category names here may
+          overlap with the C(category_names) option.
+        - Matches are a sub-string match. For example, Windows will match any
+          Windows OS update, while the category string may be Windows Server
+          2012 R2.
+        - These categories are filtered after searching for updates, while the
+          list in C(category_names) is applied during the search for updates.
+        type: list
+        version_added: '2.8'
     reboot:
         description:
         - Ansible will automatically reboot the remote host if it is required
@@ -191,6 +203,11 @@
             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


Code review comments:
diff_hunk: @@ -60,6 +61,7 @@ $update_script_block = {
     Function Start-Updates {
         Param(
             $category_guids,
+            [string[]]$post_categories=@(),
path: lib/ansible/modules/windows/win_updates.ps1
text: @user1:
Do you need to add `[AllowEmptyCollection()]` for this?

@author:
As long as there's an empty list being provided then all should be fine.

diff_hunk: @@ -184,11 +187,28 @@ $update_script_block = {
                 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
+
+            $post_cat_matched = $False
path: lib/ansible/modules/windows/win_updates.ps1
text: @user1:
Can be simplified with `$post_cat_matches = ($post_categories.Count -eq 0)`

@author:
Agreed.

diff_hunk: @@ -184,11 +187,28 @@ $update_script_block = {
                 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
+
+            $post_cat_matched = $False
+            If ($post_categories.Count -eq 0) {
+                # There are no additional categories to check
+                $post_cat_matched = $True
+            }
+
+            ForEach ($update_cat in $update.Categories) {
+                $update_info.categories.Add($update_cat.Name) | Out-Null
+                ForEach ($match_cat in $post_categories) {
+                    If ($update_cat.Name -Match $match_cat) {
+                        $post_cat_matched = $True
path: lib/ansible/modules/windows/win_updates.ps1
text: @user1:
Once we get a match, best to break the loop, no need to waste cycles if we already know there's a match.

@author:
Adjusted in next commit