Patching Windows Servers at Scale with Ansible


Patching hundreds of Windows servers does not need to require an expensive Remote Monitoring and Management platform or a large collection of custom scripts. This guide demonstrates how to automate Windows Server patching with Ansible using three separate playbooks for downloading, installing, rebooting, verifying, and reporting updates.

The design is intended for environments using Ansible from the command line, Semaphore UI, or Red Hat Ansible Automation Platform. It uses strategy: free and configurable fork counts to process many servers in parallel while keeping disruptive reboot operations inside a defined maintenance window.

What this guide covers
  • Creating a monthly patching change request
  • Downloading Windows updates before the maintenance window
  • Installing operating system updates and rebooting
  • Installing SQL Server and remaining updates
  • Filtering phantom Windows Update GUIDs
  • Scaling patching with forks and AAP job slicing
  • Producing structured results for each server

Table of Contents


Patching Architecture

The patching process is separated into three phases so that network-intensive downloads can occur before the maintenance window and disruptive installation and reboot operations can be controlled independently.

Patch Tuesday
      |
      v
Create Change Request
      |
      v
Phase 1: Download Updates
      |
      v
Maintenance Window Begins
      |
      v
Phase 2: Install Windows Updates
      |
      v
Reboot and Verify Connectivity
      |
      v
Phase 3: Install SQL and Remaining Updates
      |
      v
Final Reboot, Verification, and Reporting

The three playbooks perform the following operations:

  1. Download Updates: Search for applicable updates, filter known phantom update GUIDs, and download the selected updates without rebooting.
  2. Install Windows Updates and Reboot: Install operating system, security, cumulative, .NET, and other approved updates before rebooting and verifying connectivity.
  3. Install SQL and Remaining Updates: Install SQL Server updates and any updates that became applicable after the first reboot.
Important: Optional Windows updates may include the Azure Connected Machine Agent. Agent upgrades can interrupt Azure Arc connectivity and should only be performed during an approved maintenance window.

Prerequisites

Before using these playbooks, confirm that the following requirements are in place:

  • An Ansible control node or Ansible Automation Platform execution environment
  • The ansible.windows collection
  • WinRM access to each Windows server
  • A remote account with the permissions required to search for and install updates
  • An Ansible inventory group named windows_servers
  • Network access to WSUS or Microsoft Update
  • A defined maintenance window for installation and reboot operations
  • An Ansible Vault or credential store for API keys and passwords

The examples also support optional integration with Freshservice, Semaphore UI, and Red Hat Ansible Automation Platform. These products are not required to run the patching playbooks themselves.

Recommended documentation note

Add the exact versions of Ansible Core, the ansible.windows collection, Windows Server, Semaphore, and AAP that you tested. Version information helps readers determine whether the examples match their environment.

Why Use Ansible?

My preferred commercial patching solution has historically been Action1 because it automates the complete update lifecycle with minimal hands-on effort. However, many organizations do not want to purchase another management platform or deploy another endpoint agent alongside existing monitoring and security software.

Ansible provides an agentless alternative for orchestrating Windows patching. Once WinRM access is configured, Ansible can search for updates, download them, install them, reboot systems, verify connectivity, and publish structured results. The process remains controlled, repeatable, auditable, and suitable for source control.

Tip: I prefer built-in and vendor-supported Ansible modules whenever possible. This reduces dependency and compatibility problems when Ansible collections or Python environments are upgraded. The ansible.windows.win_shell and ansible.builtin.shell modules can also be used when a dedicated module does not provide the required functionality.

Patching Cycle Summary

The monthly workflow begins by creating a change request containing:

  • The Windows servers included in the patching scope
  • The currently available Windows Update KB numbers
  • The planned start and end of the maintenance window
  • Links to Microsoft support and release information
  • The implementation and reboot schedule

The server list is generated by enumerating the windows_servers inventory group. The update list is retrieved from Microsoft update information and added to the change request as an HTML table.

API Request or Email

The example playbook creates a Freshservice Change Request using the ansible.builtin.uri module. If your organization uses another platform, such as ServiceNow, Jira Service Management, or a custom ticketing system, replace the endpoint URL and request body with values from that platform's API documentation.

A ticket can also be created by sending an email, but an API integration generally has fewer dependencies.

Method Dependency Consideration
API Ticketing platform Provides structured requests and direct response validation
Email Email platform and ticketing platform Simpler to configure but introduces an additional point of failure

When reliability and response validation matter, the API is usually the better option.

Scaling and Future Proofing

I recommend running the playbooks through Semaphore UI or Red Hat Ansible Automation Platform when the process will be maintained by multiple administrators. A web interface, credential management, schedules, job history, notifications, and role-based access make the patching workflow easier for future team members to operate.

The patching playbooks use strategy: free so that each host can continue to its next task without waiting for the slowest server. The -f command-line option controls how many hosts Ansible processes concurrently.

  • Downloading updates primarily consumes network bandwidth.
  • Installing updates consumes CPU and disk resources on each target server.
  • Reboots create the greatest operational impact because services become temporarily unavailable.

Begin with a conservative fork count, monitor bandwidth and execution time, and increase concurrency gradually. The maximum safe value will depend on internet bandwidth, WSUS capacity, Ansible execution-node resources, storage performance, and the number of simultaneous server outages your organization can tolerate.

Scaling tip: Record the completion time for each phase. Those measurements make it easier to estimate future maintenance windows and determine whether additional bandwidth or execution capacity is required.

Automate the Change Request

Before any patching can begin we need to schedule when we will do it. We automatically discover all available updates for the current month's "Patch Tuesday", by querying Microsoft Update Catalog search results. This produces a catalog-search summary for the configured month and query. The per-server ansible.windows.win_updates search remains the authoritative applicability check.

This dedicated playbook must run on any Windows host with internet access. (I originally wrote a PowerShell script for this purpose, or it could run entirely from a Linux-based Ansible control node.) Since the script already exists, there was no need for me to duplicate the effort. Schedule this task to run every Wednesday afternoon or evening following Patch Tuesday (the second Tuesday of each month). This timing ensures the automation isn’t impacted by delays in Microsoft publishing updated patch information.

This playbook will:

  • Calculate the "Start Date and Time" and "End Date and Time" for a two-hour window on the last Thursday of the month. Modify the duration and schedule for your environment.
  • Enumerate the hosts in your Ansible inventory group windows_servers
  • Query Microsoft Update Catalog for Windows Server updates matching the configured month
  • Generate an HTML table with all patches (includes KB numbers, support article URLs, and download links)
  • Use the Freshservice API to create the Change Request.

Freshservice API fields can vary by account configuration, workspace, plan, and API version. The example below submits common change fields such as risk, impact, priority, category, department, and planned dates. Review the current Freshservice Change and Change Fields API documentation before using the payload in production.


Submit Change Request Playbook

In the playbook below you will need to define the email address of a user account in Freshservice that can create Change Requests. You also need to define valid Freshservice ID values which I have defined in the "vars" section.

Below is a working playbook after you discover and define your Freshservice instances values. There is, however, one variable, "freshservice_api_key", which should be included using Ansible Vault.
ansible-playbook -i windows_inventory.yml create_windows_patching_fscr.yml --ask-vault-pass
# or
ansible-playbook -i windows_inventory.yml create_windows_patching_fscr.yml --vault-password-file ~/.vault_pass.txt

---
- name: Submit Windows Server Patching Change Request Playbook
  hosts: server01
  gather_facts: false
  tasks:
    - name: Get the Latest Windows Updates
      ansible.windows.win_shell: |
        Add-Type -AssemblyName System.Web
        $Search = "$(Get-Date -Format 'yyyy-MM') Windows Server Cumulative Update Security"
        $Response = Invoke-WebRequest -Uri "https://www.catalog.update.microsoft.com/Search.aspx?q=$([uri]::EscapeDataString($Search))"
        $TableRegex = ']*>.*?'
        $Rows = [Regex]::Matches($Response.Content, $TableRegex, [System.Text.RegularExpressions.RegexOptions]::Singleline) | ForEach-Object { $_.Value }
        $Updates = @()
        ForEach ($Row in $Rows) {
            #Write-Output -InputObject "Processing row: $($Row.Substring(0, [Math]::Min(200, $Row.Length)))..."
            $KBMatch = [Regex]::Match($Row, '(KB\d{7})')
            $KBNumber = If ($KBMatch.Success) { $KBMatch.Groups[1].Value } Else { "N/A" }
            $ProductsRegex = ']*id="[^"]+_C2_R\d+"[^>]*>(.*?)'
            $ProductsMatch = [Regex]::Match($Row, $ProductsRegex, [System.Text.RegularExpressions.RegexOptions]::Singleline)
            $Products = If ($ProductsMatch.Success) { [System.Web.HttpUtility]::HtmlDecode($ProductsMatch.Groups[1].Value.Trim()) } Else { "N/A" }
            $ClassificationRegex = ']*id="[^"]+_C3_R\d+"[^>]*>(.*?)'
            $ClassificationMatch = [Regex]::Match($Row, $ClassificationRegex, [System.Text.RegularExpressions.RegexOptions]::Singleline)
            $Classification = If ($ClassificationMatch.Success) { [System.Web.HttpUtility]::HtmlDecode($ClassificationMatch.Groups[1].Value.Trim()) } Else { "N/A" }
            $LastUpdatedRegex = ']*id="[^"]+_C4_R\d+"[^>]*>(.*?)'
            $LastUpdatedMatch = [Regex]::Match($Row, $LastUpdatedRegex, [System.Text.RegularExpressions.RegexOptions]::Singleline)
            $LastUpdated = If ($LastUpdatedMatch.Success) { $LastUpdatedMatch.Groups[1].Value.Trim() } Else { "N/A" }
            $SizeRegex = ']*id="[^"]+_C6_R\d+"[^>]*>.*?]*id="[^"]+_size">(.*?)'
            $SizeMatch = [Regex]::Match($Row, $SizeRegex, [System.Text.RegularExpressions.RegexOptions]::Singleline)
            $Size = If ($SizeMatch.Success) { $SizeMatch.Groups[1].Value.Trim() } Else { "N/A" }
            If (
                $KBNumber -ne "N/A" -and
                $Products -match "Windows Server" -and
                $Classification -match "Security Updates|Updates"
            ) {
                $Updates += [PSCustomObject]@{
                    KBNumber      = $KBNumber
                    Products      = $Products
                    Classification = $Classification
                    LastUpdated   = $LastUpdated
                    Size          = $Size
                }
            }
        }
        $Updates = $Updates | Sort-Object -Property KBNumber -Unique
        If ($Updates.Count -eq 0) {
            Write-Output -InputObject "No updates found or parsing failed. Possible reasons:"
            Write-Output -InputObject "- Regex patterns may not match the HTML structure. Check the row output above."
            Write-Output -InputObject "- No updates with valid KB numbers (KBxxxxxxx) found for the query."
            Write-Output -InputObject "- JavaScript rendering may be required. Try removing -UseBasicParsing or using Selenium."
            Write-Output -InputObject "- Verify the generated search query '$Search' returns expected results."
        } Else {
            $Updates | ConvertTo-Html -Fragment
        }
      register: latest_windows_patches

    - name: Save latest windows patches to a file
      ansible.builtin.copy:
        content: "{{ latest_windows_patches.stdout_lines | join('\n') }}"
        dest: "/tmp/.latest_windows_patches.txt"
      delegate_to: localhost

- name: Submit Server Patching Change Request
  hosts: localhost 
  gather_facts: true
  vars:
    freshservice_domain: YOURDOMAIN.freshservice.com
    fs_requester_email: freshservice-requester@osbornepro.com
    agent_id: 00000000001           # Unique identifier of the agent to whom the change is assigned.
    group_id: 00000000002           # Unique identifier of the agent group to which the change is assigned.
    requester_id: 00000000003       # Unique identifier of the initiator of the change. (Mandatory)
    department_id: 00000000004      # Unique ID of the department initiating the change.
    cab_name: "IT CAB"              # This playbook will translate your Freshservice CABs name to an ID value
    category: Security              # Category of the change
    risk: 1                         # 1 is Low and 4 is Highest
    impact: 1                       # 1 is Low and 3 is Highest
    priority: 1                     # 1 is Low and 4 is Highest
    change_type: 1                  # 1 is Minor through 4 which is Emergency
    status: 4                       # 4 translates to Open
    latest_windows_patches_from_file: "{{ lookup('file', '/tmp/.latest_windows_patches.txt') }}"
  tasks:
    - name: Get Planned Start Date (last Thursday of the month at 22:00 ET)
      ansible.builtin.shell: |
        set -euo pipefail
        TZ="America/New_York"

        year=$(date +%Y)
        month=$(date +%m)

        last_day=$(cal "$month" "$year" | awk 'NF {d=$NF} END{print d}')
        weekday=$(date -d "$year-$month-$last_day" +%u)   # Mon=1..Sun=7

        # Thursday = 4
        sub=$(( (weekday - 4 + 7) % 7 ))
        last_thursday=$(date -d "$year-$month-$last_day - $sub days" +%Y-%m-%d)

        # ISO8601 with correct offset for that date (EST or EDT)
        date -d "$last_thursday 22:00:00" --iso-8601=seconds
      args:
        executable: /bin/bash
      register: planned_start_date_result
      changed_when: false

    - name: Set planned start date fact
      ansible.builtin.set_fact:
        planned_start_date: "{{ planned_start_date_result.stdout | trim }}"

    - name: Get planned end date (2 hours after planned start date)
      ansible.builtin.shell: |
        set -euo pipefail
        TZ="America/New_York"
        date -d "{{ planned_start_date }} + 2 hours" --iso-8601=seconds
      args:
        executable: /bin/bash
      register: planned_end_date_result
      changed_when: false

    - name: Set planned end date fact
      ansible.builtin.set_fact:
        planned_end_date: "{{ planned_end_date_result.stdout | trim }}"

    - name: Build server list from ansible inventory
      ansible.builtin.add_host:
        name: "{{ item }}"
      loop: "{{ groups['windows_servers'] | unique }}"
      changed_when: false

    - name: Set fact with HTML list of hosts
      ansible.builtin.set_fact:
        html_table: >
          <ul>
          {% for host in groups['windows_servers'] | unique %}
            <li>{{ host }}</li>
          {% endfor %}
          </ul>

    - name: Call Freshservice API to get CABs
      ansible.builtin.shell: |
        curl -s -u {{ freshservice_api_key }}:X -X GET "https://{{ freshservice_domain }}/api/v2/cabs" | jq '.cabs[] | select(.name == "{{ cab_name }}") | .id'
      register: cab_id
      no_log: true

    - name: Show the returned CAB ID
      ansible.builtin.debug:
        msg: "CAB ID for 'IT CAB' is {{ cab_id.stdout }}"

    - name: Set POST Data for Change Request
      ansible.builtin.set_fact:
        change_payload:
          change:
            email: "{{ fs_requester_email }}"
            agent_id: "{{ agent_id }}"
            group_id: "{{ group_id }}"
            subject: "{{ lookup('pipe', \"date '+%B %Y'\") }} - Windows Server Patching Request"
            description: >
              <h2>Windows Servers Being Patched</h2><p>The following Windows Server updates are scheduled for installation. Review Microsoft release notes for known issues before deployment. <a href="https://{{ freshservice_domain }}/a/solutions/articles/11111111111" target="_blank">DOCUMENTATION</a></p>{{ html_table | join('') }}
              <br><h2>Windows Updates Being Installed</h2>
              <div style="font-family: monospace;">{{ latest_windows_patches_from_file | regex_replace('\n', '<br>') }}</div>
            risk: "{{ risk }}"
            impact: "{{ impact }}"
            priority: "{{ priority }}"
            change_type: "{{ change_type }}"
            status: "{{ status }}"
            category: "{{ category }}"
            requester_id: "{{ requester_id }}"
            department_id: "{{ department_id }}"
            planned_start_date: "{{ planned_start_date }}"
            planned_end_date: "{{ planned_end_date }}"

    - name: Create Patching Change Request
      ansible.builtin.uri:
        url: "https://{{ freshservice_domain }}/api/v2/changes"
        method: POST
        user: "{{ freshservice_api_key }}"
        password: "X"
        force_basic_auth: yes
        headers:
          Content-Type: "application/json"
        body_format: json
        body: "{{ change_payload }}"
        status_code: 201
      register: create_change_response
      no_log: true

    - name: Getting the change number created
      ansible.builtin.set_fact:
        change_id: "{{ create_change_response.json.change.id }}"

    - name: Print the change ID
      ansible.builtin.debug:
        msg: "Change ID created is CHN-{{ change_id }}"

    - name: Get CAB Members for Approval
      ansible.builtin.uri:
        url: "https://{{ freshservice_domain }}/api/v2/cabs/{{ cab_id.stdout }}"
        method: GET
        user: "{{ freshservice_api_key }}"
        password: "X"
        force_basic_auth: yes
        headers:
          Content-Type: "application/json"
        return_content: yes
      register: cab_info

    - name: Show CAB lookup result
      ansible.builtin.debug:
        msg: "Retrieved CAB details for {{ cab_name }}. Add approval-group API tasks if your workflow requires automated approval routing."

    - name: Cleanup the generated updates file 
      become: true
      become_user: root
      become_method: sudo
      ansible.builtin.file:
        path: "/tmp/.latest_windows_patches.txt"
        state: absent

The screenshot below shows an example of what your Freshservice Change Request will look like.

Example Freshservice Change Request Created

The change includes:

  • Scheduled for the last Thursday of the month at 22:00 ET (two-hour example window)
  • Full HTML list of hosts being patched
  • CAB lookup information that can be extended with approval-group API tasks

With our Change Request now submitted, we need playbooks scheduled to execute the change.


Why Use a 3-Phase Approach?

The workflow separates downloading, operating system installation, and SQL Server finalization so each phase can use an appropriate maintenance policy and concurrency level.

  1. Phase 1 – Search for and download approved updates without rebooting. The task is synchronous per host, while strategy: free and forks allow many hosts to run in parallel.
  2. Phase 2 – During the maintenance window, install operating system and platform updates with reboot: true, reconnect after reboot, and verify that no approved non-SQL updates remain.
  3. Phase 3 – On SQL Server hosts, search for SQL Server servicing updates and any updates that became applicable after Phase 2, install them with automatic reboot handling, and perform a final verification search.

The ansible.windows.win_updates module does not support Ansible async mode when reboot: true is enabled. This is an Ansible module behavior, not a Microsoft requirement that cumulative updates explicitly use reboot: true. Using strategy: free prevents a slow server from holding every other host at the same task, while the job's fork count limits total host concurrency.

SQL Server safety: Use a dedicated inventory group such as sql_servers. For clustered SQL Server instances or availability groups, add application health checks, failover-aware sequencing, backups, and a strictly controlled reboot order before using the Phase 3 example.
Phase Playbook Duration Purpose
1. Download download-windows-updates.yml 2 hours Download all approved updates in parallel as your bandwidth allows
2. Install Updates with Reboot install-windows-updates.yml Maintenance window Install Operating System updates and reboot to finish installing updates.
3. Install SQL and Missing Updates with Reboot finalize-windows-updates.yml Maintenance window Then install SQL and any still missing updates. Reboot again if needed.

Key Benefits of This Strategy

  • Speed: strategy: free + -f 50 = 50 servers patched simultaneously. Use more or less to optimize in your environment
  • Safety: No reboots or outages until the maintenance window
  • Visibility: Full update list logged before download
  • Resilience: Structured per-host results, retries, and failure classification during the download phase
  • Auditability: Logs per host, per phase

Phase 1: Download Updates

Run our first ansible playbook from the command line by doing:
ansible-playbook -i windows_inventory.yml download-windows-updates.yml -f 50

The -f 50 option permits up to 50 concurrent Ansible workers for the playbook run. If it is not defined, Ansible uses its configured fork default, commonly five. Define as many forks as you are able without negatively impacting your bandwidth. This simply downloads updates so they are ready to install in the next phase. In my experience you need about 45 minutes for the playbook to finish downloading updates for each scope or fork grouping. Your environment may be different of course.


Known False-Positive Update IDs

In the playbooks below notice I have a variable named "phantom_guids". These identifiers were observed as non-actionable results in the tested environment. Update identifiers and revision data can differ between Windows Update, Microsoft Update, and WSUS, so validate each identifier in your own environment before excluding it. The way to clear these out is to delete the "SoftwareDistribution" and "catroot2" directories and let the next WSUS scan rebuild the directory contents. Not accounting for these will impact reporting results, making it seem like servers are missing updates despite being fully patched. It can be simpler to just ignore the GUID values you come across to prevent false positives in your reporting. Add only validated non-actionable GUID values to the phantom_guids list.


---
- name: Windows Server Download Updates
  hosts: windows_servers
  gather_facts: false
  strategy: free
  any_errors_fatal: false
  vars:
    update_categories:
      - SecurityUpdates
      - CriticalUpdates
      - UpdateRollups
      - DefinitionUpdates
      - ServicePacks
      - Application
      - Updates
      - Office 2016
      - '.NET Framework'
    phantom_guids:
      - "686561c1-487f-41e6-851e-343b499cd77b"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"
    pending_reboot_patterns:
      - "reboot is required"
      - "restart is required"
      - "pending reboot"
      - "pending restart"
      - "before more updates can be installed"
      - "another installation requires a reboot"
      - "0x8024a000"
      - "0x80240016"
  tasks:
    # -------------------------------------------------------------------------
    # Initialize all host-level result variables.
    # This guarantees that each reachable or unreachable inventory host has
    # a predictable result structure.
    # -------------------------------------------------------------------------
    - name: Initialize Download Result
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "MANUAL_ACTION_REQUIRED"
          success: false
          warning: false
          manual_action_required: true
          reachable: false
          search_completed: false
          pending_reboot: false
          updates_found: 0
          updates_selected: 0
          updates_failed: 0
          selected_updates: []
          failed_updates: []
          filtered_phantom_updates: []
          message: "Download processing has not completed."
          error_message: ""
        discovered_updates: []
        filtered_phantom_updates: []
        accept_list: []
        failed_updates: []
        download_failure_messages: []

    # -------------------------------------------------------------------------
    # Connectivity test
    #
    # ignore_unreachable is required because unreachable failures do not enter
    # block/rescue/always processing. This keeps the host active so that its
    # structured result can still be created.
    # -------------------------------------------------------------------------

    - name: Test Pre-Patch WinRM Connectivity
      ansible.windows.win_ping:
      register: pre_patch_ping
      ignore_unreachable: true
      ignore_errors: true

    - name: Determine Pre-Patch Connectivity
      ansible.builtin.set_fact:
        pre_patch_reachable: >-
          {{
            pre_patch_ping is defined and
            not (pre_patch_ping.unreachable | default(false)) and
            (pre_patch_ping.ping | default('')) == 'pong'
          }}

    - name: Record Unreachable Pre-Patch Result
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "UNREACHABLE_PRE_PATCH"
          success: false
          warning: false
          manual_action_required: true
          reachable: false
          search_completed: false
          pending_reboot: false
          updates_found: 0
          updates_selected: 0
          updates_failed: 0
          selected_updates: []
          failed_updates: []
          filtered_phantom_updates: []
          message: "The server was unreachable through WinRM before the update search."
          error_message: >-
            {{
              pre_patch_ping.msg
              | default('WinRM connectivity test did not return pong.')
            }}
      when: not (pre_patch_reachable | bool)

    # -------------------------------------------------------------------------
    # Windows Update search
    #
    # The search retries five times. A permanent search failure is recorded
    # instead of stopping the play for the host.
    # -------------------------------------------------------------------------

    - name: Search for Windows Updates
      ansible.windows.win_updates:
        category_names: "{{ update_categories }}"
        state: searched
        skip_optional: "{{ skip_optional_updates | default(false) | bool }}"
      register: search
      until: search is successful
      retries: 5
      delay: 60
      ignore_errors: true
      when: pre_patch_reachable | bool

    - name: Determine Whether Update Search Succeeded
      ansible.builtin.set_fact:
        update_search_successful: >-
          {{
            pre_patch_reachable | bool and
            search is defined and
            not (search.failed | default(false)) and
            not (search.unreachable | default(false))
          }}

    - name: Record Windows Update Search Failure
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "SEARCH_FAILED"
          success: false
          warning: false
          manual_action_required: true
          reachable: true
          search_completed: false
          pending_reboot: "{{ search.reboot_required | default(false) | bool }}"
          updates_found: 0
          updates_selected: 0
          updates_failed: 0
          selected_updates: []
          failed_updates: []
          filtered_phantom_updates: []
          message: "The server was reachable, but the Windows Update search failed."
          error_message: >-
            {{
              search.msg
              | default(
                  search.exception
                  | default('Windows Update search failed without a detailed message.')
                )
            }}
      when:
        - pre_patch_reachable | bool
        - not (update_search_successful | bool)

    # -------------------------------------------------------------------------
    # Preserve complete update information and filter phantom update IDs.
    # -------------------------------------------------------------------------

    - name: Build Discovered and Phantom Update Lists
      ansible.builtin.set_fact:
        discovered_updates: >-
          {{
            discovered_updates +
            (
              [
                item.value
                | combine(
                    {
                      'update_id': item.key,
                      'normalized_update_id': item.key | lower
                    }
                  )
              ]
              if (item.key | lower) not in phantom_guids
              else []
            )
          }}
        filtered_phantom_updates: >-
          {{
            filtered_phantom_updates +
            (
              [
                {
                  'update_id': item.key,
                  'title': item.value.title | default('Unknown update'),
                  'kb': item.value.kb | default([])
                }
              ]
              if (item.key | lower) in phantom_guids
              else []
            )
          }}
      loop: "{{ search.updates | default({}) | dict2items }}"
      loop_control:
        label: "{{ item.value.title | default(item.key) }}"
      when: update_search_successful | bool

    # -------------------------------------------------------------------------
    # Build accept_list entries from update KB numbers.
    #
    # KB entries are normalized into KB1234567 format.
    # -------------------------------------------------------------------------

    - name: Build KB-Based Update Accept List
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            (
              accept_list +
              [
                item.1
                | string
                | trim
                | regex_replace('^(KB)?0*(\d+)$', 'KB\2')
              ]
            )
            | unique
            | list
          }}
      loop: >-
        {{
          query(
            'subelements',
            discovered_updates,
            'kb',
            {
              'skip_missing': true
            }
          )
        }}
      loop_control:
        label: >-
          {{
            item.0.title | default('Unknown update')
          }}
      when:
        - update_search_successful | bool
        - item.1 | default('') | string | trim | length > 0

    # -------------------------------------------------------------------------
    # Some updates do not have KB numbers.
    #
    # win_updates accept_list can match update titles, so an exact escaped
    # title expression is added for updates whose KB list is empty.
    # -------------------------------------------------------------------------

    - name: Add Title-Based Selectors for Updates Without KB Numbers
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            (
              accept_list +
              [
                '^' ~
                (item.title | default('') | regex_escape) ~
                '$'
              ]
            )
            | unique
            | list
          }}
      loop: "{{ discovered_updates }}"
      loop_control:
        label: "{{ item.title | default(item.update_id) }}"
      when:
        - update_search_successful | bool
        - item.title | default('') | length > 0
        - item.kb | default([]) | length == 0

    - name: Record Number of Updates Selected
      ansible.builtin.set_fact:
        selected_update_count: "{{ discovered_updates | length }}"
      when: update_search_successful | bool

    - name: Build Update Download Selection Summary
      ansible.builtin.set_fact:
        update_download_selection_summary:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD_SELECTION"
          updates_found: >-
            {{
              search.found_update_count
              | default(search.updates | default({}) | length)
              | int
            }}
          real_updates_selected: "{{ selected_update_count | default(0) | int }}"
          phantom_updates_excluded: "{{ filtered_phantom_updates | length }}"
          accept_list: "{{ accept_list }}"
          selected_updates: "{{ discovered_updates }}"
          filtered_phantom_updates: "{{ filtered_phantom_updates }}"
      when: update_search_successful | bool

    - name: Show Updates Selected for Download
      ansible.builtin.debug:
        var: update_download_selection_summary
      when: update_search_successful | bool

    # -------------------------------------------------------------------------
    # No applicable updates
    # -------------------------------------------------------------------------

    - name: Record No Updates Result
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "NO_UPDATES"
          success: true
          warning: false
          manual_action_required: false
          reachable: true
          search_completed: true
          pending_reboot: "{{ search.reboot_required | default(false) | bool }}"
          updates_found: "{{ search.found_update_count | default(0) | int }}"
          updates_selected: 0
          updates_failed: 0
          selected_updates: []
          failed_updates: []
          filtered_phantom_updates: "{{ filtered_phantom_updates }}"
          message: >-
            No applicable updates remained after filtering known phantom
            updates and the configured update categories.
          error_message: ""
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int == 0

    # -------------------------------------------------------------------------
    # Download updates
    #
    # No async is used here so that the full module and update-level failure
    # information remains available.
    #
    # The task is allowed to return a failed result without stopping the host.
    # -------------------------------------------------------------------------

    - name: Download Discovered Windows Updates
      ansible.windows.win_updates:
        state: downloaded
        accept_list: "{{ accept_list }}"
        log_path: >-
          {{
            update_log_path
            | default('C:\Windows\Temp\ansible-win-updates.log')
          }}
        skip_optional: "{{ skip_optional_updates | default(false) | bool }}"
        reboot: false
      register: download_result
      ignore_errors: true
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0

    # -------------------------------------------------------------------------
    # Preserve update-level failure information.
    # -------------------------------------------------------------------------

    - name: Build Failed Update Details
      ansible.builtin.set_fact:
        failed_updates: >-
          {{
            failed_updates +
            [
              {
                'update_id': item.key,
                'title': item.value.title | default('Unknown update'),
                'kb': item.value.kb | default([]),
                'failure_hresult_code':
                  item.value.failure_hresult_code | default(''),
                'failure_msg':
                  item.value.failure_msg | default(''),
                'downloaded':
                  item.value.downloaded | default(false),
                'installed':
                  item.value.installed | default(false)
              }
            ]
          }}
      loop: "{{ download_result.updates | default({}) | dict2items }}"
      loop_control:
        label: "{{ item.value.title | default(item.key) }}"
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0
        - >-
          item.value.failure_msg is defined or
          item.value.failure_hresult_code is defined

    - name: Build Download Failure Message List
      ansible.builtin.set_fact:
        download_failure_messages: >-
          {{
            download_failure_messages +
            (
              [item.failure_msg]
              if item.failure_msg | default('') | length > 0
              else []
            )
          }}
      loop: "{{ failed_updates }}"
      loop_control:
        label: "{{ item.title }}"
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0

    # -------------------------------------------------------------------------
    # Combine all available failure information for pending reboot detection.
    # -------------------------------------------------------------------------

    - name: Build Combined Download Error Text
      ansible.builtin.set_fact:
        combined_download_error_text: >-
          {{
            (
              [
                download_result.msg | default(''),
                download_result.exception | default('')
              ]
              +
              download_failure_messages
            )
            | join(' ')
            | lower
          }}
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0

    - name: Initialize Pending Reboot Detection
      ansible.builtin.set_fact:
        download_blocked_pending_reboot: >-
          {{
            download_result.reboot_required
            | default(false)
            | bool
          }}
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0

    - name: Detect Pending Reboot from Download Error Messages
      ansible.builtin.set_fact:
        download_blocked_pending_reboot: true
      loop: "{{ pending_reboot_patterns }}"
      loop_control:
        label: "{{ item }}"
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0
        - item | lower in combined_download_error_text | default('')

    # -------------------------------------------------------------------------
    # Normalize the reliable result fields returned by win_updates.
    #
    # The module does not provide a dependable downloaded-update count for
    # state: downloaded, so classification uses task failure and per-update
    # failure details instead.
    # -------------------------------------------------------------------------

    - name: Normalize Download Result Counts
      ansible.builtin.set_fact:
        updates_found_count: >-
          {{
            download_result.found_update_count
            | default(selected_update_count | default(0))
            | int
          }}
        updates_failed_count: >-
          {{
            download_result.failed_update_count
            | default(failed_updates | length)
            | int
          }}
        download_task_failed: >-
          {{
            download_result.failed
            | default(false)
            | bool
          }}
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0

    # -------------------------------------------------------------------------
    # Classification: pending reboot
    # -------------------------------------------------------------------------

    - name: Record Download Deferred by Pending Reboot
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "DOWNLOAD_DEFERRED_REBOOT"
          success: true
          warning: true
          manual_action_required: false
          reachable: true
          search_completed: true
          pending_reboot: true
          updates_found: "{{ updates_found_count }}"
          updates_selected: "{{ selected_update_count | int }}"
          updates_failed: "{{ updates_failed_count }}"
          selected_updates: "{{ discovered_updates }}"
          failed_updates: "{{ failed_updates }}"
          filtered_phantom_updates: "{{ filtered_phantom_updates }}"
          message: >-
            A pending reboot prevented or interrupted the update download.
            The server can continue to the installation phase, where the
            pending reboot condition must be handled before updates are
            installed.
          error_message: >-
            {{
              download_result.msg
              | default(
                  download_failure_messages
                  | join('; ')
                )
            }}
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0
        - download_blocked_pending_reboot | default(false) | bool

    # -------------------------------------------------------------------------
    # Classification: partial download
    # -------------------------------------------------------------------------

    - name: Record Partial Download Result
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "DOWNLOAD_PARTIAL"
          success: false
          warning: true
          manual_action_required: true
          reachable: true
          search_completed: true
          pending_reboot: >-
            {{
              download_result.reboot_required
              | default(false)
              | bool
            }}
          updates_found: "{{ updates_found_count }}"
          updates_selected: "{{ selected_update_count | int }}"
          updates_failed: "{{ updates_failed_count }}"
          selected_updates: "{{ discovered_updates }}"
          failed_updates: "{{ failed_updates }}"
          filtered_phantom_updates: "{{ filtered_phantom_updates }}"
          message: >-
            Some updates downloaded successfully, but one or more updates
            failed to download.
          error_message: >-
            {{
              download_failure_messages
              | join('; ')
              | default(
                  download_result.msg
                  | default('One or more updates failed to download.'),
                  true
                )
            }}
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0
        - not (download_blocked_pending_reboot | default(false) | bool)
        - updates_failed_count | int > 0
        - updates_failed_count | int < selected_update_count | int

    # -------------------------------------------------------------------------
    # Classification: complete download failure
    # -------------------------------------------------------------------------

    - name: Record Download Failure
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "DOWNLOAD_FAILED"
          success: false
          warning: false
          manual_action_required: true
          reachable: true
          search_completed: true
          pending_reboot: >-
            {{
              download_result.reboot_required
              | default(false)
              | bool
            }}
          updates_found: "{{ updates_found_count }}"
          updates_selected: "{{ selected_update_count | int }}"
          updates_failed: "{{ updates_failed_count }}"
          selected_updates: "{{ discovered_updates }}"
          failed_updates: "{{ failed_updates }}"
          filtered_phantom_updates: "{{ filtered_phantom_updates }}"
          message: "The selected updates could not be downloaded successfully."
          error_message: >-
            {{
              download_result.msg
              | default(
                  download_failure_messages
                  | join('; ')
                  | default('Unknown Windows Update download error.', true)
                )
            }}
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0
        - not (download_blocked_pending_reboot | default(false) | bool)
        - >-
          (
            updates_failed_count | int >= selected_update_count | int or
            (
              download_task_failed | bool and
              updates_failed_count | int == 0
            )
          )

    # -------------------------------------------------------------------------
    # Classification: successful download
    # -------------------------------------------------------------------------

    - name: Record Successful Download
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "DOWNLOADED"
          success: true
          warning: false
          manual_action_required: false
          reachable: true
          search_completed: true
          pending_reboot: >-
            {{
              download_result.reboot_required
              | default(false)
              | bool
            }}
          updates_found: "{{ updates_found_count }}"
          updates_selected: "{{ selected_update_count | int }}"
          updates_failed: "{{ updates_failed_count }}"
          selected_updates: "{{ discovered_updates }}"
          failed_updates: "{{ failed_updates }}"
          filtered_phantom_updates: "{{ filtered_phantom_updates }}"
          message: >-
            Successfully completed the Windows Update download phase.
          error_message: ""
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0
        - not (download_blocked_pending_reboot | default(false) | bool)
        - updates_failed_count | int == 0
        - not (download_task_failed | bool)

    # -------------------------------------------------------------------------
    # Catch any result that was not safely classified.
    # -------------------------------------------------------------------------

    - name: Record Unclassified Download Result
      ansible.builtin.set_fact:
        patch_download_result:
          hostname: "{{ inventory_hostname }}"
          phase: "DOWNLOAD"
          status: "MANUAL_ACTION_REQUIRED"
          success: false
          warning: false
          manual_action_required: true
          reachable: true
          search_completed: true
          pending_reboot: >-
            {{
              download_result.reboot_required
              | default(false)
              | bool
            }}
          updates_found: "{{ updates_found_count | default(0) | int }}"
          updates_selected: "{{ selected_update_count | default(0) | int }}"
          updates_failed: "{{ updates_failed_count | default(0) | int }}"
          selected_updates: "{{ discovered_updates }}"
          failed_updates: "{{ failed_updates }}"
          filtered_phantom_updates: "{{ filtered_phantom_updates }}"
          message: >-
            The download operation returned a result that could not be safely
            classified.
          error_message: >-
            {{
              download_result.msg
              | default('No detailed download error was returned.')
            }}
      when:
        - update_search_successful | bool
        - selected_update_count | default(0) | int > 0
        - patch_download_result.status == "MANUAL_ACTION_REQUIRED"

    # -------------------------------------------------------------------------
    # Per-host operator output and AAP stats.
    # -------------------------------------------------------------------------

    - name: Show Structured Download Result
      ansible.builtin.debug:
        var: patch_download_result

    - name: Publish Per-Host Download Result
      ansible.builtin.set_stats:
        data:
          patch_download_result: "{{ patch_download_result }}"
        per_host: true
        aggregate: false

Phase 2: Install Updates and Reboot

Run using the command-line:
ansible-playbook -i windows_inventory.yml install-windows-updates.yml -f 50

This will install the previously downloaded updates, excluding SQL Server updates and reboot the servers to complete their installation. SQL updates will be installed in the third phase after the Operating System updates complete. Oftentimes SQL updates will be unable to install until the Operating System is up to date. We want patching to be completed when our ansible tasks have executed, we do not want to have to run them all over again because SQL updates failed to install everywhere.


---
- name: Install Windows Updates and Reboot Non-Primary Servers (Not SQL)
  hosts: windows_servers:!primary_dc
  gather_facts: false
  strategy: free
  any_errors_fatal: false
  vars:
    IsPrimaryDc: false
    ResultHeading: "WINDOWS PATCHING RESULT"
    WindowsUpdateCategories:
      - CriticalUpdates
      - SecurityUpdates
      - UpdateRollups
      - Updates
      - DefinitionUpdates
      - ServicePacks
      - Application
      - '.NET Framework'
    PhantomGuids:
      - "686561C1-487F-41E6-851E-343B499Cd77B"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"
    RebootTimeoutSeconds: 3600
  tasks: &WindowsPatchTasks
    - name: Initialize Windows Patching Result
      ansible.builtin.set_fact:
        PatchStatus: "UNKNOWN"
        PatchStage: "Initialization"
        PatchMessage: ""
        ActionRequired: "Review the job output."
        ApprovedKbList: []
        RemainingKbList: []
        FailedUpdateSummary: []
        UpdatesFound: 0
        UpdatesInstalled: 0
        UpdatesFailed: 0
        UpdateRebootPerformed: false
        ForcedRebootPerformed: false
        MonthlyRebootCompleted: false
        PrePatchConnectivityPassed: false
        PostPatchConnectivityPassed: false
        UpdateSearchFailed: false
        UpdateInstallFailed: false
        VerificationFailed: false
        RebootFailed: false

    - name: Test Pre-Patch WinRM Connectivity
      ansible.windows.win_ping:
      register: PrePatchPing
      ignore_unreachable: true
      failed_when: false

    - name: Record Pre-Patch Connectivity Result
      ansible.builtin.set_fact:
        PrePatchConnectivityPassed: >-
          {{
            not (PrePatchPing.unreachable | default(false))
            and not (PrePatchPing.failed | default(false))
            and (PrePatchPing.ping | default('')) == 'pong'
          }}

    - name: Record Pre-Patch Connectivity Failure
      ansible.builtin.set_fact:
        PatchStatus: "FAILED"
        PatchStage: "Pre-Patch Connectivity"
        PatchMessage: >-
          WinRM connectivity failed before patching.
          {{
            PrePatchPing.msg
            | default('No additional WinRM error was returned.')
          }}
        ActionRequired: >-
          Verify DNS resolution, WinRM, firewall access, credentials,
          and that the server is powered on.
      when: not PrePatchConnectivityPassed | bool

    - name: Search for Approved Available Updates
      when: PrePatchConnectivityPassed | bool
      block:
        - name: Search Windows Update
          ansible.windows.win_updates:
            category_names: "{{ WindowsUpdateCategories }}"
            state: searched
          register: UpdateSearch
          retries: 3
          delay: 60
          until: UpdateSearch is successful

        - name: Record Number of Updates Found
          ansible.builtin.set_fact:
            UpdatesFound: "{{ UpdateSearch.found_update_count | default(0) | int }}"

        - name: Build Approved KB List
          ansible.builtin.set_fact:
            ApprovedKbList: >-
              {{
                UpdateSearch.updates | default({})
                | dict2items
                | rejectattr('key', 'in', PhantomGuids)
                | map(attribute='value.kb')
                | flatten
                | select
                | map('trim')
                | map('regex_replace', '^(KB)?0*(\d+)$', 'KB\2')
                | unique
                | list
              }}

      rescue:
        - name: Record Windows Update Search Failure
          ansible.builtin.set_fact:
            UpdateSearchFailed: true
            PatchStatus: "FAILED"
            PatchStage: "Windows Update Search"
            PatchMessage: >-
              Windows Update search failed after three attempts.
              {{
                ansible_failed_result.msg
                | default('No additional Windows Update search error was returned.')
              }}
            ActionRequired: >-
              Review the Windows Update service, WSUS or Microsoft Update
              connectivity, and the Windows Update log on the server.

    - name: Build Windows Patching Start Summary
      ansible.builtin.set_fact:
        WindowsPatchingStartSummary:
          server: "{{ inventory_hostname }}"
          server_role: >-
            {{
              'PRIMARY DOMAIN CONTROLLER'
              if IsPrimaryDc
              else 'STANDARD SERVER'
            }}
          applicable_updates_found: "{{ UpdatesFound | int }}"
          approved_kb_count: "{{ ApprovedKbList | length }}"
          approved_kbs: "{{ ApprovedKbList }}"
      when:
        - PrePatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool

    - name: Display Approved Updates
      ansible.builtin.debug:
        var: WindowsPatchingStartSummary
      when:
        - PrePatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool

    - name: Install Approved Available Updates
      ansible.windows.win_updates:
        state: installed
        accept_list: "{{ ApprovedKbList }}"
        reboot: true
        reboot_timeout: "{{ RebootTimeoutSeconds }}"
        log_path: "{{ update_log_path }}"
      register: UpdateInstall
      ignore_unreachable: true
      failed_when: false
      when:
        - PrePatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool
        - ApprovedKbList | length > 0

    - name: Record Windows Update Installation Results
      ansible.builtin.set_fact:
        UpdatesInstalled: "{{ UpdateInstall.installed_update_count | default(0) | int }}"
        UpdatesFailed: "{{ UpdateInstall.failed_update_count | default(0) | int }}"
        UpdateRebootPerformed: "{{ UpdateInstall.rebooted | default(false) | bool }}"
        MonthlyRebootCompleted: "{{ UpdateInstall.rebooted | default(false) | bool }}"
        UpdateInstallFailed: >-
          {{
            (UpdateInstall.unreachable | default(false))
            or
            (UpdateInstall.failed | default(false))
            or
            ((UpdateInstall.failed_update_count | default(0) | int) > 0)
          }}
      when:
        - PrePatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool
        - ApprovedKbList | length > 0

    - name: Build Failed Update Summary
      ansible.builtin.set_fact:
        FailedUpdateSummary: >-
          {{
            FailedUpdateSummary
            +
            [
              (
                (
                  item.value.kb
                  | default([])
                  | map('regex_replace', '^(KB)?0*(\d+)$', 'KB\2')
                  | join(', ')
                )
                if (item.value.kb | default([]) | length > 0)
                else 'KB Unknown'
              )
              +
              ' | '
              +
              (item.value.title | default('Unknown update'))
              +
              ' | '
              +
              (
                item.value.failure_msg
                | default(UpdateInstall.msg)
                | default('No failure message was returned.')
              )
            ]
          }}
      loop: "{{ UpdateInstall.updates | default({}) | dict2items }}"
      loop_control:
        label: "{{ item.value.title | default(item.key) }}"
      when:
        - UpdateInstall is defined
        - not item.value.installed | default(false)

    - name: Record Windows Update Installation Failure
      ansible.builtin.set_fact:
        PatchStatus: "FAILED"
        PatchStage: "Windows Update Installation"
        PatchMessage: >-
          {{
            UpdateInstall.msg
            | default('One or more approved updates failed to install.')
          }}
        ActionRequired: >-
          Review the failed KB list and Windows Update log. Check for
          servicing-stack problems, rollback events, pending reboots,
          and update-loop conditions.
      when:
        - UpdateInstall is defined
        - UpdateInstallFailed | bool

    - name: Force Monthly Reboot When Windows Update Did Not Reboot
      ansible.windows.win_reboot:
        msg: >-
          Monthly scheduled server reboot initiated by
          Ansible Automation Platform.
        pre_reboot_delay: 15
        post_reboot_delay: 30
        reboot_timeout: "{{ RebootTimeoutSeconds }}"
        connect_timeout: 30
        test_command: >-
          powershell.exe -NoProfile -NonInteractive
          -Command "exit 0"
      register: ForcedReboot
      ignore_unreachable: true
      failed_when: false
      when:
        - PrePatchConnectivityPassed | bool
        - not MonthlyRebootCompleted | bool

    - name: Record Forced Monthly Reboot Result
      ansible.builtin.set_fact:
        ForcedRebootPerformed: >-
          {{
            not (ForcedReboot.unreachable | default(false))
            and not (ForcedReboot.failed | default(false))
            and (ForcedReboot.rebooted | default(false) | bool)
          }}
        MonthlyRebootCompleted: >-
          {{
            not (ForcedReboot.unreachable | default(false))
            and not (ForcedReboot.failed | default(false))
            and (ForcedReboot.rebooted | default(false) | bool)
          }}
        RebootFailed: >-
          {{
            (ForcedReboot.unreachable | default(false))
            or
            (ForcedReboot.failed | default(false))
            or
            not (ForcedReboot.rebooted | default(false) | bool)
          }}
      when:
        - ForcedReboot is defined
        - not (ForcedReboot.skipped | default(false) | bool)

    - name: Record Forced Monthly Reboot Failure
      ansible.builtin.set_fact:
        PatchStatus: "FAILED"
        PatchStage: "Monthly Server Reboot"
        PatchMessage: >-
          The required monthly reboot did not complete successfully.
          {{
            ForcedReboot.msg
            | default('No additional reboot error was returned.')
          }}
        ActionRequired: >-
          Verify the server state and WinRM connectivity. Reboot the
          server manually if needed and confirm that core services return.
      when:
        - ForcedReboot is defined
        - not (ForcedReboot.skipped | default(false) | bool)
        - RebootFailed | bool

    - name: Wait for WinRM to become available after reboot
      ansible.builtin.wait_for_connection:
        connect_timeout: 30
        sleep: 15
        timeout: "{{ RebootTimeoutSeconds }}"
      register: PostRebootConnectionWait
      ignore_unreachable: true
      failed_when: false
      when:
        - PrePatchConnectivityPassed | bool
        - MonthlyRebootCompleted | bool

    - name: Reset the WinRM connection after reboot
      ansible.builtin.meta: reset_connection

    - name: Test fresh post-reboot WinRM connectivity
      ansible.windows.win_ping:
      register: PostPatchPing
      retries: 12
      delay: 15
      until:
        - not (PostPatchPing.unreachable | default(false))
        - not (PostPatchPing.failed | default(false))
        - (PostPatchPing.ping | default('')) == 'pong'
      ignore_unreachable: true
      failed_when: false
      when:
        - PrePatchConnectivityPassed | bool
        - MonthlyRebootCompleted | bool
        - PostRebootConnectionWait is defined
        - not (PostRebootConnectionWait.unreachable | default(false))
        - not (PostRebootConnectionWait.failed | default(false))

    - name: Record post-reboot connectivity result
      ansible.builtin.set_fact:
        PostPatchConnectivityPassed: >-
          {{
            PostRebootConnectionWait is defined
            and not (PostRebootConnectionWait.unreachable | default(false))
            and not (PostRebootConnectionWait.failed | default(false))
            and PostPatchPing is defined
            and not (PostPatchPing.skipped | default(false))
            and not (PostPatchPing.unreachable | default(false))
            and not (PostPatchPing.failed | default(false))
            and (PostPatchPing.ping | default('')) == 'pong'
          }}
      when:
        - PrePatchConnectivityPassed | bool
        - MonthlyRebootCompleted | bool

    - name: Record Post-Reboot Connectivity Failure
      ansible.builtin.set_fact:
        PatchStatus: "FAILED"
        PatchStage: "Post-Reboot Connectivity"
        PatchMessage: >-
          The server did not restore usable WinRM connectivity after reboot.
          {{
            PostPatchPing.msg
            | default(
                PostRebootConnectionWait.msg
                | default('No additional post-reboot WinRM error was returned.')
              )
          }}
        ActionRequired: >-
          Verify that the server completed startup, WinRM is running,
          networking is available, and the Ansible credential can log in.
      when:
        - PrePatchConnectivityPassed | bool
        - MonthlyRebootCompleted | bool
        - not PostPatchConnectivityPassed | bool

    - name: Verify Remaining Approved Updates
      when:
        - PrePatchConnectivityPassed | bool
        - PostPatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool
      block:
        - name: Run Final Windows Update Search
          ansible.windows.win_updates:
            category_names: "{{ WindowsUpdateCategories }}"
            state: searched
          register: VerificationSearch
          retries: 3
          delay: 60
          until: VerificationSearch is successful

        - name: Build Remaining Approved KB List
          ansible.builtin.set_fact:
            RemainingKbList: >-
              {{
                VerificationSearch.updates | default({})
                | dict2items
                | rejectattr('key', 'in', PhantomGuids)
                | map(attribute='value.kb')
                | flatten
                | select
                | map('trim')
                | map('regex_replace', '^(KB)?0*(\d+)$', 'KB\2')
                | unique
                | list
              }}

      rescue:
        - name: Record Verification Search Failure
          ansible.builtin.set_fact:
            VerificationFailed: true
            PatchStatus: >-
              {{ 'FAILED' if PatchStatus == 'FAILED' else 'WARNING' }}
            PatchStage: >-
              {{ PatchStage if PatchStatus == 'FAILED' else 'Post-Patch Verification' }}
            PatchMessage: >-
              {{
                PatchMessage
                if PatchStatus == 'FAILED'
                else
                (
                  'The monthly reboot completed, but the final Windows Update '
                  'verification search failed. '
                  +
                  (
                    ansible_failed_result.msg
                    | default('No additional verification error was returned.')
                  )
                )
              }}
            ActionRequired: >-
              Review the Windows Update log and rerun the update-search job
              to confirm whether approved updates remain.

    - name: Record Successful No-Update Monthly Reboot
      ansible.builtin.set_fact:
        PatchStatus: "SUCCESS"
        PatchStage: "Completed"
        PatchMessage: >-
          No approved applicable updates were found.
          The required monthly server reboot completed successfully.
        ActionRequired: "None."
      when:
        - PrePatchConnectivityPassed | bool
        - PostPatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool
        - not UpdateInstallFailed | bool
        - not VerificationFailed | bool
        - not RebootFailed | bool
        - ApprovedKbList | length == 0
        - RemainingKbList | length == 0

    - name: Record Successful Update Installation
      ansible.builtin.set_fact:
        PatchStatus: "SUCCESS"
        PatchStage: "Completed"
        PatchMessage: >-
          Approved updates were installed, the required monthly reboot
          completed, and no approved applicable updates remain.
        ActionRequired: "None."
      when:
        - PrePatchConnectivityPassed | bool
        - PostPatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool
        - not UpdateInstallFailed | bool
        - not VerificationFailed | bool
        - not RebootFailed | bool
        - ApprovedKbList | length > 0
        - RemainingKbList | length == 0

    - name: Record Updates Remaining After Installation
      ansible.builtin.set_fact:
        PatchStatus: >-
          {{ 'FAILED' if UpdateInstallFailed else 'WARNING' }}
        PatchStage: >-
          {{ PatchStage if UpdateInstallFailed else 'Post-Patch Verification' }}
        PatchMessage: >-
          {{
            PatchMessage
            if UpdateInstallFailed
            else
            (
              'The update installation and required monthly reboot completed, '
              'but approved applicable updates remain: '
              + (RemainingKbList | join(', '))
            )
          }}
        ActionRequired: >-
          Review the remaining KB list. Determine whether another install
          pass, a prerequisite update, or manual remediation is required.
      when:
        - PrePatchConnectivityPassed | bool
        - PostPatchConnectivityPassed | bool
        - not UpdateSearchFailed | bool
        - not VerificationFailed | bool
        - not RebootFailed | bool
        - RemainingKbList | length > 0

    - name: Preserve Installation Failure After Successful Reboot
      ansible.builtin.set_fact:
        PatchStatus: "FAILED"
        PatchStage: "Windows Update Installation"
        ActionRequired: >-
          Review the failed KB list and Windows Update log. The monthly
          reboot completed, but one or more updates still failed.
      when:
        - UpdateInstallFailed | bool
        - MonthlyRebootCompleted | bool

    - name: Build Windows Patching Result Summary
      ansible.builtin.set_fact:
        WindowsPatchingResultSummary:
          heading: "{{ ResultHeading }}"
          server: "{{ inventory_hostname }}"
          server_role: >-
            {{
              'PRIMARY DOMAIN CONTROLLER'
              if IsPrimaryDc
              else 'STANDARD SERVER'
            }}
          status: "{{ PatchStatus }}"
          stage: "{{ PatchStage }}"
          update_results:
            applicable_updates_found: "{{ UpdatesFound | int }}"
            approved_kbs_selected: "{{ ApprovedKbList | length }}"
            approved_kbs: "{{ ApprovedKbList }}"
            updates_installed: "{{ UpdatesInstalled | int }}"
            updates_failed: "{{ UpdatesFailed | int }}"
            approved_kbs_remaining_count: "{{ RemainingKbList | length }}"
            approved_kbs_remaining: "{{ RemainingKbList }}"
            failed_updates: "{{ FailedUpdateSummary }}"
          reboot_results:
            rebooted_by_windows_update: "{{ UpdateRebootPerformed | bool }}"
            forced_monthly_reboot: "{{ ForcedRebootPerformed | bool }}"
            monthly_reboot_completed: "{{ MonthlyRebootCompleted | bool }}"
            post_reboot_connectivity_passed: "{{ PostPatchConnectivityPassed | bool }}"
          result_message: "{{ PatchMessage }}"
          action_required: "{{ ActionRequired }}"
          windows_update_log: "{{ update_log_path }}"

    - name: Display Windows Patching Result
      ansible.builtin.debug:
        var: WindowsPatchingResultSummary

    - name: Mark Server Patching as Failed
      ansible.builtin.fail:
        msg: >-
          Windows patching failed on {{ inventory_hostname }} during
          {{ PatchStage }}. {{ PatchMessage }}
          Failed updates:
          {{
            FailedUpdateSummary | join('; ')
            if FailedUpdateSummary | length > 0
            else 'None identified'
          }}
      when: PatchStatus == "FAILED"


- name: Install Windows Updates and Reboot Primary DC Last
  hosts: primary_dc
  gather_facts: false
  strategy: linear
  serial: 1
  any_errors_fatal: true

  vars:
    IsPrimaryDc: true
    ResultHeading: "PRIMARY DOMAIN CONTROLLER PATCHING RESULT"

    WindowsUpdateCategories:
      - CriticalUpdates
      - SecurityUpdates
      - UpdateRollups
      - Updates
      - DefinitionUpdates
      - ServicePacks
      - Application
      - '.NET Framework'

    PhantomGuids:
      - "686561C1-487F-41E6-851E-343B499Cd77B"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"

    RebootTimeoutSeconds: 3600

  tasks: *WindowsPatchTasks

Phase 3: Finalize SQL Server and Remaining Updates

Run this phase only against servers that host SQL Server and place them in a dedicated inventory group such as sql_servers:

ansible-playbook -i windows_inventory.yml finalize-windows-updates.yml -f 25

The playbook searches the update categories exposed by each server, including the SQL Server category, builds selectors from KB numbers or exact update titles, installs applicable updates with automatic reboot handling, reconnects through WinRM, and performs a final verification search. The category name must exist in the update source configured for the target. WSUS administrators should confirm that the required SQL Server products, classifications, and updates are approved.

Important: This generic example is not cluster-aware. Do not run it across clustered SQL Server nodes or availability-group replicas without pre-checks, backups, failover planning, health validation, and an explicit node order.
---
- name: Finalize SQL Server and Remaining Windows Updates
  hosts: sql_servers:!primary_dc
  gather_facts: false
  strategy: free
  any_errors_fatal: false

  vars:
    skip_optional_updates: false
    update_log_path: 'C:\Windows\Logs\ansible-sql-finalize-updates.log'
    reboot_timeout_seconds: 3600

    update_categories:
      - CriticalUpdates
      - SecurityUpdates
      - UpdateRollups
      - Updates
      - DefinitionUpdates
      - ServicePacks
      - SQL Server
      - Application
      - '.NET Framework'

    phantom_guids:
      - "686561c1-487f-41e6-851e-343b499cd77b"
      - "069a8283-ad7c-4a4c-9a0c-a77de1424c93"

  tasks:
    - name: Initialize SQL Finalization Result
      ansible.builtin.set_fact:
        sql_finalize_result:
          hostname: "{{ inventory_hostname }}"
          phase: "SQL_FINALIZATION"
          status: "MANUAL_ACTION_REQUIRED"
          success: false
          updates_found: 0
          updates_selected: 0
          updates_installed: 0
          updates_failed: 0
          rebooted: false
          remaining_updates: []
          message: "SQL finalization has not completed."
          error_message: ""
        discovered_updates: []
        accept_list: []
        failed_updates: []
        remaining_updates: []

    - name: Test Pre-Finalization WinRM Connectivity
      ansible.windows.win_ping:
      register: pre_finalize_ping
      ignore_unreachable: true
      failed_when: false

    - name: Determine Pre-Finalization Connectivity
      ansible.builtin.set_fact:
        pre_finalize_reachable: >-
          {{
            not (pre_finalize_ping.unreachable | default(false))
            and not (pre_finalize_ping.failed | default(false))
            and (pre_finalize_ping.ping | default('')) == 'pong'
          }}

    - name: Search for SQL Server and Remaining Updates
      ansible.windows.win_updates:
        category_names: "{{ update_categories }}"
        state: searched
        skip_optional: "{{ skip_optional_updates | bool }}"
      register: finalize_search
      retries: 5
      delay: 60
      until: finalize_search is successful
      when: pre_finalize_reachable | bool

    - name: Preserve Real Discovered Updates
      ansible.builtin.set_fact:
        discovered_updates: >-
          {{
            discovered_updates
            +
            [
              item.value
              | combine(
                  {
                    'update_id': item.key,
                    'normalized_update_id': item.key | lower
                  }
                )
            ]
          }}
      loop: "{{ finalize_search.updates | default({}) | dict2items }}"
      loop_control:
        label: "{{ item.value.title | default(item.key) }}"
      when:
        - pre_finalize_reachable | bool
        - item.key | lower not in phantom_guids

    - name: Add KB Selectors
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            (
              accept_list
              +
              [
                item.1
                | string
                | trim
                | regex_replace('^(KB)?0*(\d+)$', 'KB\2')
              ]
            )
            | unique
            | list
          }}
      loop: >-
        {{
          query(
            'subelements',
            discovered_updates,
            'kb',
            {'skip_missing': true}
          )
        }}
      loop_control:
        label: "{{ item.0.title | default('Unknown update') }}"
      when: item.1 | default('') | string | trim | length > 0

    - name: Add Exact Title Selectors for Updates Without KB Numbers
      ansible.builtin.set_fact:
        accept_list: >-
          {{
            (
              accept_list
              +
              ['^' ~ (item.title | regex_escape) ~ '$']
            )
            | unique
            | list
          }}
      loop: "{{ discovered_updates }}"
      loop_control:
        label: "{{ item.title | default(item.update_id) }}"
      when:
        - item.title | default('') | length > 0
        - item.kb | default([]) | length == 0

    - name: Show SQL Finalization Selection
      ansible.builtin.debug:
        msg:
          hostname: "{{ inventory_hostname }}"
          updates_found: "{{ finalize_search.found_update_count | default(0) | int }}"
          updates_selected: "{{ discovered_updates | length }}"
          accept_list: "{{ accept_list }}"

    - name: Install SQL Server and Remaining Updates
      ansible.windows.win_updates:
        category_names: "{{ update_categories }}"
        state: installed
        accept_list: "{{ accept_list }}"
        skip_optional: "{{ skip_optional_updates | bool }}"
        reboot: true
        reboot_timeout: "{{ reboot_timeout_seconds }}"
        log_path: "{{ update_log_path }}"
      register: finalize_install
      ignore_unreachable: true
      failed_when: false
      when: accept_list | length > 0

    - name: Build Failed Update Details
      ansible.builtin.set_fact:
        failed_updates: >-
          {{
            failed_updates
            +
            [
              {
                'update_id': item.key,
                'title': item.value.title | default('Unknown update'),
                'kb': item.value.kb | default([]),
                'failure_hresult_code':
                  item.value.failure_hresult_code | default(''),
                'failure_msg':
                  item.value.failure_msg
                  | default(finalize_install.msg)
                  | default('No failure message was returned.')
              }
            ]
          }}
      loop: "{{ finalize_install.updates | default({}) | dict2items }}"
      loop_control:
        label: "{{ item.value.title | default(item.key) }}"
      when:
        - finalize_install is defined
        - not item.value.installed | default(false)

    - name: Wait for WinRM After SQL Finalization
      ansible.builtin.wait_for_connection:
        connect_timeout: 30
        sleep: 15
        timeout: "{{ reboot_timeout_seconds }}"
      register: finalize_connection_wait
      ignore_unreachable: true
      failed_when: false
      when:
        - pre_finalize_reachable | bool
        - >-
          (
            accept_list | length == 0
            or
            finalize_install.rebooted | default(false)
            or
            not (finalize_install.unreachable | default(false))
          )

    - name: Reset WinRM Connection
      ansible.builtin.meta: reset_connection
      when:
        - finalize_connection_wait is defined
        - not (finalize_connection_wait.unreachable | default(false))
        - not (finalize_connection_wait.failed | default(false))

    - name: Run Final Verification Search
      ansible.windows.win_updates:
        category_names: "{{ update_categories }}"
        state: searched
        skip_optional: "{{ skip_optional_updates | bool }}"
      register: final_verification_search
      retries: 3
      delay: 60
      until: final_verification_search is successful
      when:
        - finalize_connection_wait is defined
        - not (finalize_connection_wait.unreachable | default(false))
        - not (finalize_connection_wait.failed | default(false))

    - name: Build Remaining Update Summary
      ansible.builtin.set_fact:
        remaining_updates: >-
          {{
            remaining_updates
            +
            [
              {
                'update_id': item.key,
                'title': item.value.title | default('Unknown update'),
                'kb': item.value.kb | default([])
              }
            ]
          }}
      loop: "{{ final_verification_search.updates | default({}) | dict2items }}"
      loop_control:
        label: "{{ item.value.title | default(item.key) }}"
      when:
        - final_verification_search is defined
        - item.key | lower not in phantom_guids

    - name: Record SQL Finalization Result
      ansible.builtin.set_fact:
        sql_finalize_result:
          hostname: "{{ inventory_hostname }}"
          phase: "SQL_FINALIZATION"
          status: >-
            {{
              'UNREACHABLE_PRE_PATCH'
              if not (pre_finalize_reachable | bool)
              else
              (
                'FAILED'
                if (
                  finalize_install is defined
                  and
                  (
                    finalize_install.unreachable | default(false)
                    or finalize_install.failed | default(false)
                    or (finalize_install.failed_update_count | default(0) | int) > 0
                  )
                )
                else
                (
                  'WARNING'
                  if remaining_updates | length > 0
                  else 'SUCCESS'
                )
              )
            }}
          success: >-
            {{
              pre_finalize_reachable | bool
              and not (
                finalize_install is defined
                and
                (
                  finalize_install.unreachable | default(false)
                  or finalize_install.failed | default(false)
                  or (finalize_install.failed_update_count | default(0) | int) > 0
                )
              )
              and remaining_updates | length == 0
            }}
          updates_found: "{{ finalize_search.found_update_count | default(0) | int }}"
          updates_selected: "{{ discovered_updates | length }}"
          updates_installed: "{{ finalize_install.installed_update_count | default(0) | int }}"
          updates_failed: "{{ finalize_install.failed_update_count | default(failed_updates | length) | int }}"
          rebooted: "{{ finalize_install.rebooted | default(false) | bool }}"
          failed_updates: "{{ failed_updates }}"
          remaining_updates: "{{ remaining_updates }}"
          message: >-
            {{
              'The host was unreachable before SQL finalization.'
              if not (pre_finalize_reachable | bool)
              else
              (
                'One or more SQL Server or remaining updates failed.'
                if failed_updates | length > 0
                else
                (
                  'Updates remain after SQL finalization.'
                  if remaining_updates | length > 0
                  else
                  (
                    'No SQL Server or remaining updates were applicable.'
                    if discovered_updates | length == 0
                    else
                    'SQL Server and remaining updates completed successfully.'
                  )
                )
              )
            }}
          error_message: >-
            {{
              pre_finalize_ping.msg
              | default(
                  finalize_install.msg
                  | default('')
                )
            }}

    - name: Show Structured SQL Finalization Result
      ansible.builtin.debug:
        var: sql_finalize_result

    - name: Publish Per-Host SQL Finalization Result
      ansible.builtin.set_stats:
        data:
          sql_finalize_result: "{{ sql_finalize_result }}"
        per_host: true
        aggregate: false

    - name: Fail Host When SQL Finalization Failed
      ansible.builtin.fail:
        msg: >-
          SQL finalization failed on {{ inventory_hostname }}.
          {{ sql_finalize_result.message }}
      when: sql_finalize_result.status in ['UNREACHABLE_PRE_PATCH', 'FAILED']

Rebuild the Windows Update Cache

Use this optional repair playbook only after troubleshooting indicates that the local Windows Update cache should be rebuilt. It stops the required services, renames the cache directories through PowerShell, reboots when a directory was moved, and verifies that Windows recreated the directories. Keep the renamed folders until update scanning and installation have been validated.

---
- name: Rebuild Windows Update Cache
  hosts: windows_servers
  gather_facts: false

  vars:
    reboot_timeout: 900
    service_stop_timeout: 120
    rename_suffix: "_old_{{ lookup('pipe', 'date +%Y%m%dT%H%M%S') }}"

    cache_folders:
      - name: Catroot2
        source: 'C:\Windows\System32\catroot2'
        destination: 'C:\Windows\System32\catroot2{{ rename_suffix }}'
      - name: SoftwareDistribution
        source: 'C:\Windows\SoftwareDistribution'
        destination: 'C:\Windows\SoftwareDistribution{{ rename_suffix }}'

    update_services:
      - { name: wuauserv, display: "Windows Update" }
      - { name: BITS, display: "Background Intelligent Transfer Service" }
      - { name: CryptSvc, display: "Cryptographic Services" }
      - { name: msiserver, display: "Windows Installer" }

  tasks:
    - name: Stop Windows Update Services
      ansible.windows.win_service:
        name: "{{ item.name }}"
        state: stopped
      loop: "{{ update_services }}"
      loop_control:
        label: "{{ item.display }} ({{ item.name }})"
      register: service_stop_result
      retries: 3
      delay: 10
      until: service_stop_result is successful

    - name: Rename Windows Update Cache Folders
      ansible.windows.win_powershell:
        script: |
          param(
            [string]$Source,
            [string]$Destination
          )

          if (-not (Test-Path -LiteralPath $Source)) {
              $Ansible.Changed = $false
              $Ansible.Result = @{
                  source = $Source
                  destination = $Destination
                  moved = $false
                  reason = 'Source folder did not exist.'
              }
              return
          }

          if (Test-Path -LiteralPath $Destination) {
              Remove-Item -LiteralPath $Destination -Recurse -Force
          }

          Move-Item -LiteralPath $Source -Destination $Destination -Force
          $Ansible.Changed = $true
          $Ansible.Result = @{
              source = $Source
              destination = $Destination
              moved = $true
          }
        parameters:
          Source: "{{ item.source }}"
          Destination: "{{ item.destination }}"
      loop: "{{ cache_folders }}"
      loop_control:
        label: "{{ item.source }} -> {{ item.destination }}"
      register: cache_rename_result

    - name: Determine Whether a Reboot Is Required
      ansible.builtin.set_fact:
        reboot_required: >-
          {{
            cache_rename_result.results
            | selectattr('changed', 'equalto', true)
            | list
            | length > 0
          }}

    - name: Start Windows Update Services When No Reboot Is Needed
      ansible.windows.win_service:
        name: "{{ item.name }}"
        state: started
      loop: "{{ update_services }}"
      loop_control:
        label: "{{ item.display }} ({{ item.name }})"
      when: not (reboot_required | bool)

    - name: Reboot to Regenerate Windows Update Cache
      ansible.windows.win_reboot:
        msg: "Rebooting to regenerate Windows Update cache directories."
        reboot_timeout: "{{ reboot_timeout }}"
        post_reboot_delay: 30
        connect_timeout: 30
      when: reboot_required | bool

    - name: Wait for Windows Update Cache Folders
      ansible.windows.win_powershell:
        script: |
          param([string]$Path)

          if (-not (Test-Path -LiteralPath $Path)) {
              throw "Folder has not been recreated: $Path"
          }

          $Ansible.Changed = $false
          $Ansible.Result = @{
              path = $Path
              exists = $true
          }
        parameters:
          Path: "{{ item.source }}"
      loop: "{{ cache_folders }}"
      loop_control:
        label: "{{ item.source }}"
      register: cache_folder_check
      retries: 12
      delay: 15
      until: cache_folder_check is successful
      when: reboot_required | bool

    - name: Show Cache Rebuild Result
      ansible.builtin.debug:
        msg:
          hostname: "{{ inventory_hostname }}"
          reboot_performed: "{{ reboot_required | bool }}"
          backup_suffix: "{{ rename_suffix }}"
          backup_folders: "{{ cache_folders | map(attribute='destination') | list }}"

Windows Server Post Patching Report

This is a playbook you can use to send a pretty post patching HTML report by email. This assumes you have an SMTP relay server to use for sending the email.


---
- name: Windows Post Patching Validation
  hosts: windows_servers
  gather_facts: true
  any_errors_fatal: false
  ignore_unreachable: true
  vars:
    # Allow busy Windows servers more time to start Ansible async tasks.
    ansible_win_async_startup_timeout: 60

    # Set to true to exclude optional/preview updates from compliance.
    skip_optional_updates: false

    # Number of days of installed hotfix history to include.
    installed_update_lookback_days: 14

    # Windows Update search log location on each Windows server.
    update_log_directory: 'C:\Windows\Temp'
    update_log_path: 'C:\Windows\Temp\ansible-windows-update-search.log'

  tasks:
    - name: Ensure Windows Update log directory exists
      ansible.windows.win_file:
        path: "{{ update_log_directory }}"
        state: directory
      failed_when: false

    - name: Perform Windows post-patching validation
      block:
        - name: Discover all remaining Microsoft updates
          ansible.windows.win_updates:
            category_names:
              - '*'
            state: searched
            skip_optional: "{{ skip_optional_updates | bool }}"
            log_path: "{{ update_log_path }}"
          register: windows_update_search
          async: 3600
          poll: 60

        - name: Check Windows pending reboot indicators
          ansible.windows.win_powershell:
            script: |
              $requiredReboot = $false
              $rebootWarning = $false

              $requiredReasons = [System.Collections.Generic.List[string]]::new()
              $warningReasons = [System.Collections.Generic.List[string]]::new()

              # Treat these indicators as authoritative reboot requirements.
              $requiredRegistryChecks = @(
                  @{
                      Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
                      Reason = 'Component Based Servicing'
                  },
                  @{
                      Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired'
                      Reason = 'Windows Update'
                  }
              )

              foreach ($check in $requiredRegistryChecks) {
                  if (Test-Path -LiteralPath $check.Path) {
                      $requiredReboot = $true
                      $requiredReasons.Add($check.Reason)
                  }
              }

              # Treat PendingFileRenameOperations alone as a warning. Some
              # applications recreate this value shortly after startup.
              $sessionManagerPath =
                  'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager'

              $pendingFileRename = Get-ItemProperty `
                  -Path $sessionManagerPath `
                  -Name 'PendingFileRenameOperations' `
                  -ErrorAction SilentlyContinue

              if (
                  $null -ne $pendingFileRename -and
                  $null -ne $pendingFileRename.PendingFileRenameOperations
              ) {
                  $renameEntries = @(
                      $pendingFileRename.PendingFileRenameOperations
                  ) | Where-Object {
                      -not [string]::IsNullOrWhiteSpace([string]$_)
                  }

                  if ($renameEntries.Count -gt 0) {
                      $rebootWarning = $true
                      $warningReasons.Add('Pending file rename operations')
                  }
              }

              try {
                  $configManagerResult = Invoke-CimMethod `
                      -Namespace 'root\ccm\ClientSDK' `
                      -ClassName 'CCM_ClientUtilities' `
                      -MethodName 'DetermineIfRebootPending' `
                      -ErrorAction Stop

                  if (
                      $configManagerResult.RebootPending -or
                      $configManagerResult.IsHardRebootPending
                  ) {
                      $requiredReboot = $true
                      $requiredReasons.Add('Configuration Manager client')
                  }
              }
              catch {
                  # The Configuration Manager client namespace may not exist.
              }

              [PSCustomObject]@{
                  RequiredReboot  = $requiredReboot
                  RebootWarning   = $rebootWarning
                  RequiredReasons = @(
                      $requiredReasons | Select-Object -Unique
                  )
                  WarningReasons  = @(
                      $warningReasons | Select-Object -Unique
                  )
              }
          register: pending_reboot_check
          changed_when: false
          failed_when: false

        - name: Get recently installed Windows hotfixes
          ansible.windows.win_powershell:
            parameters:
              LookbackDays: "{{ installed_update_lookback_days | int }}"
            script: |
              param(
                  [int]$LookbackDays
              )

              $cutoffDate = (Get-Date).AddDays(-$LookbackDays)

              Get-CimInstance -ClassName Win32_QuickFixEngineering |
                  ForEach-Object {
                      $installedDate = $null

                      if ($_.InstalledOn) {
                          try {
                              $installedDate = [datetime]$_.InstalledOn
                          }
                          catch {
                              $installedDate = $null
                          }
                      }

                      [PSCustomObject]@{
                          HotFixID    = [string]$_.HotFixID
                          Description = [string]$_.Description
                          InstalledBy = [string]$_.InstalledBy
                          InstalledOn = $installedDate
                      }
                  } |
                  Where-Object {
                      $null -ne $_.InstalledOn -and
                      $_.InstalledOn -ge $cutoffDate
                  } |
                  Sort-Object InstalledOn -Descending |
                  ForEach-Object {
                      [PSCustomObject]@{
                          HotFixID    = $_.HotFixID
                          Description = $_.Description
                          InstalledBy = $_.InstalledBy
                          InstalledOn = $_.InstalledOn.ToString(
                              'yyyy-MM-dd HH:mm:ss'
                          )
                      }
                  }
          register: recent_hotfixes
          changed_when: false
          failed_when: false

        - name: Store successful Windows patch results
          ansible.builtin.set_fact:
            windows_patch_result:
              hostname: "{{ inventory_hostname }}"
              connection_status: "SUCCESS"

              os_name: >-
                {{
                  ansible_distribution
                  | default('Microsoft Windows')
                }}

              os_version: >-
                {{
                  ansible_distribution_version
                  | default('Unknown')
                }}

              architecture: >-
                {{
                  ansible_architecture
                  | default('Unknown')
                }}

              last_boot: >-
                {{
                  ansible_lastboot
                  | default('Unknown')
                }}

              found_update_count: >-
                {{
                  windows_update_search.found_update_count
                  | default(
                      windows_update_search.updates
                      | default({})
                      | length
                  )
                  | int
                }}

              failed_update_count: >-
                {{
                  windows_update_search.failed_update_count
                  | default(0)
                  | int
                }}

              updates: >-
                {{
                  windows_update_search.updates
                  | default({})
                }}

              filtered_updates: >-
                {{
                  windows_update_search.filtered_updates
                  | default({})
                }}

              win_updates_reboot_required: >-
                {{
                  windows_update_search.reboot_required
                  | default(false)
                  | bool
                }}

              pending_reboot: >-
                {{
                  (
                    pending_reboot_check.output
                    | default([])
                    | first
                    | default({})
                  ).RequiredReboot
                  | default(false)
                  | bool
                }}

              reboot_warning: >-
                {{
                  (
                    pending_reboot_check.output
                    | default([])
                    | first
                    | default({})
                  ).RebootWarning
                  | default(false)
                  | bool
                }}

              reboot_reasons: >-
                {{
                  (
                    pending_reboot_check.output
                    | default([])
                    | first
                    | default({})
                  ).RequiredReasons
                  | default([])
                }}

              reboot_warning_reasons: >-
                {{
                  (
                    pending_reboot_check.output
                    | default([])
                    | first
                    | default({})
                  ).WarningReasons
                  | default([])
                }}

              recently_installed_updates: >-
                {{
                  recent_hotfixes.output
                  | default([])
                }}

              recent_hotfix_count: >-
                {{
                  recent_hotfixes.output
                  | default([])
                  | length
                }}

              patch_status: >-
                {{
                  'PASSED'
                  if
                  (
                    windows_update_search.found_update_count
                    | default(
                        windows_update_search.updates
                        | default({})
                        | length
                    )
                    | int
                  ) == 0
                  and
                  (
                    windows_update_search.failed_update_count
                    | default(0)
                    | int
                  ) == 0
                  else 'FAILED'
                }}

              error_message: ""

      rescue:
        - name: Store failed Windows patch results
          ansible.builtin.set_fact:
            windows_patch_result:
              hostname: "{{ inventory_hostname }}"
              connection_status: "FAILED"

              os_name: >-
                {{
                  ansible_distribution
                  | default('Microsoft Windows')
                }}

              os_version: >-
                {{
                  ansible_distribution_version
                  | default('Unknown')
                }}

              architecture: >-
                {{
                  ansible_architecture
                  | default('Unknown')
                }}

              last_boot: >-
                {{
                  ansible_lastboot
                  | default('Unknown')
                }}

              found_update_count: 0
              failed_update_count: 0
              updates: {}
              filtered_updates: {}
              win_updates_reboot_required: false
              pending_reboot: false
              reboot_warning: false
              reboot_reasons: []
              reboot_warning_reasons: []
              recently_installed_updates: []
              recent_hotfix_count: 0
              patch_status: "ERROR"

              error_message: >-
                {{
                  ansible_failed_result.msg
                  | default('Windows update validation failed.')
                }}


- name: Generate and email consolidated Windows report
  hosts: localhost
  connection: local
  gather_facts: true
  vars:
    ansible_connection: local
    ansible_python_interpreter: "{{ ansible_playbook_python }}"
    ansible_become: false

    windows_inventory_group: "windows_servers"

    # These defaults should match the first play.
    skip_optional_updates: false
    installed_update_lookback_days: 14

    report_dir: "/tmp/windows-post-patching"

    report_file: >-
      {{ report_dir }}/windows-post-patching-report-{{ ansible_date_time.date }}.html

    smtp_server: "smtp.example.com"
    smtp_port: 25
    email_from: "ansible@example.com"

  tasks:
    - name: Define Windows report server list
      ansible.builtin.set_fact:
        windows_report_servers: >-
          {{
            groups[windows_inventory_group]
            | default([])
          }}

    - name: Verify the Windows inventory group contains servers
      ansible.builtin.assert:
        that:
          - windows_report_servers | length > 0
        fail_msg: >-
          No servers were found in the
          {{ windows_inventory_group }} inventory group.

    - name: Create report directory on AAP execution node
      ansible.builtin.file:
        path: "{{ report_dir }}"
        state: directory
        mode: '0755'

    - name: Generate consolidated Windows HTML report
      ansible.builtin.copy:
        dest: "{{ report_file }}"
        mode: '0644'
        content: |
          <!DOCTYPE html>
          <html lang="en">
          <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">

            <title>Windows Post Patching Validation Report</title>

            <style>
              body {
                font-family: Arial, Helvetica, sans-serif;
                color: #222222;
                background-color: #ffffff;
                margin: 20px;
                line-height: 1.4;
              }

              h1 {
                background-color: #333333;
                color: #ffffff;
                padding: 15px;
                margin-bottom: 8px;
              }

              h2 {
                color: #333333;
                border-bottom: 2px solid #cccccc;
                padding-bottom: 5px;
                margin-top: 30px;
              }

              h3 {
                margin-bottom: 8px;
              }

              .report-metadata {
                color: #555555;
                margin-bottom: 20px;
              }

              .summary-cards {
                display: table;
                width: 100%;
                table-layout: fixed;
                margin: 20px 0;
              }

              .summary-card {
                display: table-cell;
                border: 1px solid #cccccc;
                padding: 12px;
                text-align: center;
                background-color: #f5f5f5;
              }

              .summary-number {
                display: block;
                font-size: 24px;
                font-weight: bold;
              }

              table {
                border-collapse: collapse;
                width: 100%;
                margin-bottom: 25px;
              }

              th {
                background-color: #555555;
                color: #ffffff;
                text-align: left;
              }

              th,
              td {
                border: 1px solid #cccccc;
                padding: 8px;
                vertical-align: top;
              }

              tr:nth-child(even) {
                background-color: #f9f9f9;
              }

              .PASSED {
                color: #15803d;
                font-weight: bold;
              }

              .FAILED,
              .ERROR,
              .UNREACHABLE {
                color: #b91c1c;
                font-weight: bold;
              }

              .WARNING {
                color: #b45309;
                font-weight: bold;
              }

              .server-section {
                border: 1px solid #cccccc;
                padding: 15px;
                margin-bottom: 20px;
              }

              .no-updates {
                color: #15803d;
                font-weight: bold;
              }

              .error-box {
                color: #b91c1c;
                background-color: #fef2f2;
                border: 1px solid #fecaca;
                padding: 10px;
              }

              .reboot-box {
                color: #92400e;
                background-color: #fffbeb;
                border: 1px solid #fde68a;
                padding: 10px;
              }

              .small-text {
                color: #666666;
                font-size: 12px;
              }

              .update-title {
                font-weight: bold;
              }

              .report-link {
                color: inherit;
                font-weight: bold;
                text-decoration: underline;
                text-underline-offset: 2px;
              }

              .report-link:hover {
                text-decoration-thickness: 2px;
              }

              .section-anchor {
                scroll-margin-top: 20px;
              }

              .back-link {
                display: inline-block;
                margin-top: 10px;
                font-size: 12px;
              }
            </style>
          </head>

          <body>
            <h1>Windows Post Patching Validation Report</h1>

            <div class="report-metadata">
              <b>Generated:</b>
              {{ ansible_date_time.iso8601 | escape }}
              <br>

              <b>Inventory group:</b>
              {{ windows_inventory_group | escape }}
              <br>

              <b>Optional updates included:</b>
              {{
                (
                  not (
                    skip_optional_updates
                    | default(false)
                    | bool
                  )
                )
                | ternary('Yes', 'No')
              }}
              <br>

              <b>Installed update history:</b>
              Previous
              {{ installed_update_lookback_days | default(14) }}
              days
            </div>

            {% set report_ns = namespace(
              passed=0,
              failed=0,
              errors=0,
              unreachable=0,
              reboot_required=0,
              reboot_warnings=0,
              outstanding_updates=0
            ) %}

            {% for server in windows_report_servers %}
              {% set result =
                hostvars[server].windows_patch_result
                | default({})
              %}

              {% if result | length == 0 %}
                {% set report_ns.unreachable =
                  report_ns.unreachable + 1
                %}
              {% elif result.patch_status | default('ERROR') == 'PASSED' %}
                {% set report_ns.passed =
                  report_ns.passed + 1
                %}
              {% elif result.patch_status | default('ERROR') == 'FAILED' %}
                {% set report_ns.failed =
                  report_ns.failed + 1
                %}
              {% else %}
                {% set report_ns.errors =
                  report_ns.errors + 1
                %}
              {% endif %}

              {% if
                result.pending_reboot | default(false) | bool
                or
                result.win_updates_reboot_required
                | default(false)
                | bool
              %}
                {% set report_ns.reboot_required =
                  report_ns.reboot_required + 1
                %}
              {% elif
                result.reboot_warning
                | default(false)
                | bool
              %}
                {% set report_ns.reboot_warnings =
                  report_ns.reboot_warnings + 1
                %}
              {% endif %}

              {% set report_ns.outstanding_updates =
                report_ns.outstanding_updates
                +
                (
                  result.found_update_count
                  | default(0)
                  | int
                )
              %}
            {% endfor %}

            <h2>Executive Summary</h2>

            <div class="summary-cards">
              <div class="summary-card">
                <span class="summary-number">
                  {{ windows_report_servers | length }}
                </span>
                Servers
              </div>

              <div class="summary-card">
                <span class="summary-number PASSED">
                  {{ report_ns.passed }}
                </span>
                Passed
              </div>

              <div class="summary-card">
                <span class="summary-number FAILED">
                  {{ report_ns.failed }}
                </span>
                Failed
              </div>

              <div class="summary-card">
                <span class="summary-number ERROR">
                  {{ report_ns.errors + report_ns.unreachable }}
                </span>
                Errors/Unreachable
              </div>

              <div class="summary-card">
                <span class="summary-number WARNING">
                  {{ report_ns.reboot_required }}
                </span>
                Reboot Required
              </div>

              <div class="summary-card">
                <span class="summary-number WARNING">
                  {{ report_ns.reboot_warnings }}
                </span>
                Reboot Warnings
              </div>

              <div class="summary-card">
                <span class="summary-number">
                  {{ report_ns.outstanding_updates }}
                </span>
                Available Updates
              </div>
            </div>

            <h2 id="server-compliance-summary">Server Compliance Summary</h2>

            <table>
              <tr>
                <th>Server</th>
                <th>Operating System</th>
                <th>Architecture</th>
                <th>Available Updates</th>
                <th>Recently Installed</th>
                <th>Status</th>
                <th>Reboot Status</th>
              </tr>

              {% for server in windows_report_servers %}
                {% set result =
                  hostvars[server].windows_patch_result
                  | default({})
                %}

                {% if result | length == 0 %}
                  <tr>
                    <td>{{ server | escape }}</td>
                    <td>Unknown</td>
                    <td>Unknown</td>
                    <td>Unknown</td>
                    <td>Unknown</td>
                    <td class="UNREACHABLE">UNREACHABLE</td>
                    <td>Unknown</td>
                  </tr>
                {% else %}
                  <tr>
                    <td>
                      <a
                        class="report-link"
                        href="#available-{{
                          server
                          | lower
                          | regex_replace('[^a-z0-9_-]', '-')
                        }}">
                        {{
                          result.hostname
                          | default(server)
                          | escape
                        }}
                      </a>
                    </td>

                    <td>
                      {{
                        result.os_name
                        | default('Microsoft Windows')
                        | escape
                      }}
                      <br>

                      <span class="small-text">
                        Version:
                        {{
                          result.os_version
                          | default('Unknown')
                          | escape
                        }}
                      </span>
                    </td>

                    <td>
                      {{
                        result.architecture
                        | default('Unknown')
                        | escape
                      }}
                    </td>

                    <td>
                      <a
                        class="report-link"
                        href="#available-{{
                          server
                          | lower
                          | regex_replace('[^a-z0-9_-]', '-')
                        }}">
                        {{
                          result.found_update_count
                          | default(0)
                        }}
                      </a>
                    </td>

                    <td>
                      <a
                        class="report-link"
                        href="#installed-{{
                          server
                          | lower
                          | regex_replace('[^a-z0-9_-]', '-')
                        }}">
                        {{
                          result.recent_hotfix_count
                          | default(0)
                        }}
                      </a>
                    </td>

                    <td class="{{ result.patch_status | default('ERROR') | escape }}">
                      {{
                        result.patch_status
                        | default('ERROR')
                        | escape
                      }}
                    </td>

                    <td>
                      {% if
                        result.pending_reboot
                        | default(false)
                        | bool
                        or
                        result.win_updates_reboot_required
                        | default(false)
                        | bool
                      %}
                        <span class="FAILED">Required</span>
                      {% elif
                        result.reboot_warning
                        | default(false)
                        | bool
                      %}
                        <span class="WARNING">Warning</span>
                      {% else %}
                        <span class="PASSED">None</span>
                      {% endif %}
                    </td>
                  </tr>
                {% endif %}
              {% endfor %}
            </table>

            <h2 id="available-windows-updates">Available Windows Updates</h2>

            {% for server in windows_report_servers %}
              {% set result =
                hostvars[server].windows_patch_result
                | default({})
              %}

              <div
                id="available-{{
                  server
                  | lower
                  | regex_replace('[^a-z0-9_-]', '-')
                }}"
                class="server-section section-anchor">
                <h3>{{ server | escape }} — Available Updates</h3>

                {% if result | length == 0 %}
                  <div class="error-box">
                    The server was unreachable or did not return a result.
                  </div>

                {% elif
                  result.connection_status
                  | default('FAILED') != 'SUCCESS'
                %}
                  <div class="error-box">
                    <b>Validation error:</b>
                    {{
                      result.error_message
                      | default('Unknown error')
                      | escape
                    }}
                  </div>

                {% elif
                  result.updates
                  | default({})
                  | length == 0
                %}
                  <p class="no-updates">
                    No available Windows updates were detected.
                  </p>

                {% else %}
                  <p>
                    <b>
                      {{
                        result.found_update_count
                        | default(0)
                      }}
                      update(s) are currently available.
                    </b>
                  </p>

                  <table>
                    <tr>
                      <th>KB</th>
                      <th>Update Title</th>
                      <th>Categories</th>
                      <th>Downloaded</th>
                      <th>Installed</th>
                    </tr>

                    {% for update_item in
                      result.updates
                      | default({})
                      | dict2items
                    %}
                      <tr>
                        <td>
                          {% if
                            update_item.value.kb
                            | default([])
                            | length > 0
                          %}
                            {{
                              update_item.value.kb
                              | join(', ')
                              | escape
                            }}
                          {% else %}
                            Not provided
                          {% endif %}
                        </td>

                        <td class="update-title">
                          {{
                            update_item.value.title
                            | default('Unknown update')
                            | escape
                          }}
                        </td>

                        <td>
                          {{
                            update_item.value.categories
                            | default([])
                            | join(', ')
                            | escape
                          }}
                        </td>

                        <td>
                          {{
                            update_item.value.downloaded
                            | default(false)
                            | ternary('Yes', 'No')
                          }}
                        </td>

                        <td>
                          {{
                            update_item.value.installed
                            | default(false)
                            | ternary('Yes', 'No')
                          }}
                        </td>
                      </tr>
                    {% endfor %}
                  </table>
                {% endif %}

                <a
                  class="report-link back-link"
                  href="#server-compliance-summary">
                  Back to server compliance summary
                </a>
              </div>
            {% endfor %}

            <h2 id="recently-installed-windows-hotfixes">
              Recently Installed Windows Hotfixes
            </h2>

            <p class="small-text">
              This section shows hotfixes reported by
              Win32_QuickFixEngineering during the configured lookback
              period. Some Microsoft updates may not populate this data
              source.
            </p>

            {% for server in windows_report_servers %}
              {% set result =
                hostvars[server].windows_patch_result
                | default({})
              %}

              <div
                id="installed-{{
                  server
                  | lower
                  | regex_replace('[^a-z0-9_-]', '-')
                }}"
                class="server-section section-anchor">
                <h3>{{ server | escape }} — Recently Installed Updates</h3>

                {% if result | length == 0 %}
                  <div class="error-box">
                    Installed update history was unavailable because the
                    server did not return a result.
                  </div>

                {% elif
                  result.recently_installed_updates
                  | default([])
                  | length == 0
                %}
                  <p>
                    No hotfixes with a populated installation date were
                    detected during the previous
                    {{ installed_update_lookback_days | default(14) }}
                    days.
                  </p>

                {% else %}
                  <table>
                    <tr>
                      <th>KB/Hotfix</th>
                      <th>Description</th>
                      <th>Installed On</th>
                      <th>Installed By</th>
                    </tr>

                    {% for hotfix in
                      result.recently_installed_updates
                      | default([])
                    %}
                      <tr>
                        <td>
                          {{
                            hotfix.HotFixID
                            | default('Unknown')
                            | escape
                          }}
                        </td>

                        <td>
                          {{
                            hotfix.Description
                            | default('')
                            | escape
                          }}
                        </td>

                        <td>
                          {{
                            hotfix.InstalledOn
                            | default('Unknown')
                            | escape
                          }}
                        </td>

                        <td>
                          {{
                            hotfix.InstalledBy
                            | default('Unknown')
                            | escape
                          }}
                        </td>
                      </tr>
                    {% endfor %}
                  </table>
                {% endif %}

                <a
                  class="report-link back-link"
                  href="#server-compliance-summary">
                  Back to server compliance summary
                </a>
              </div>
            {% endfor %}

            <h2>Pending Reboot Details</h2>

            {% for server in windows_report_servers %}
              {% set result =
                hostvars[server].windows_patch_result
                | default({})
              %}

              <div class="server-section">
                <h3>{{ server | escape }}</h3>

                {% if result | length == 0 %}
                  <div class="error-box">
                    Reboot status could not be determined.
                  </div>

                {% elif
                  result.pending_reboot
                  | default(false)
                  | bool
                  or
                  result.win_updates_reboot_required
                  | default(false)
                  | bool
                %}
                  <div class="reboot-box">
                    <b>A reboot is required.</b>

                    {% if
                      result.reboot_reasons
                      | default([])
                      | length > 0
                    %}
                      <br>
                      <b>Authoritative indicators:</b>
                      {{
                        result.reboot_reasons
                        | join(', ')
                        | escape
                      }}
                    {% endif %}

                    {% if
                      result.win_updates_reboot_required
                      | default(false)
                      | bool
                    %}
                      <br>
                      The Windows Update API also reported that a reboot
                      is required.
                    {% endif %}

                    {% if
                      result.reboot_warning
                      | default(false)
                      | bool
                      and
                      result.reboot_warning_reasons
                      | default([])
                      | length > 0
                    %}
                      <br>
                      <b>Additional warning indicators:</b>
                      {{
                        result.reboot_warning_reasons
                        | join(', ')
                        | escape
                      }}
                    {% endif %}
                  </div>

                {% elif
                  result.reboot_warning
                  | default(false)
                  | bool
                %}
                  <div class="reboot-box">
                    <b>Reboot warning only.</b>
                    <br>
                    No authoritative reboot-required indicator was
                    detected.

                    {% if
                      result.reboot_warning_reasons
                      | default([])
                      | length > 0
                    %}
                      <br>
                      <b>Warning indicators:</b>
                      {{
                        result.reboot_warning_reasons
                        | join(', ')
                        | escape
                      }}
                    {% endif %}

                    <br>
                    Pending file rename operations may be recreated by
                    applications, drivers, backup agents, or security
                    software after startup. Review the queued paths
                    before scheduling another reboot.
                  </div>

                {% else %}
                  <p class="no-updates">
                    No pending reboot indicators were detected.
                  </p>
                {% endif %}
              </div>
            {% endfor %}

            <h2>Validation Errors</h2>

            {% set error_ns = namespace(count=0) %}

            {% for server in windows_report_servers %}
              {% set result =
                hostvars[server].windows_patch_result
                | default({})
              %}

              {% if result | length == 0 %}
                {% set error_ns.count = error_ns.count + 1 %}

                <div class="error-box">
                  <b>{{ server | escape }}:</b>
                  Server was unreachable or did not return validation data.
                </div>
                <br>

              {% elif
                result.patch_status
                | default('ERROR') == 'ERROR'
              %}
                {% set error_ns.count = error_ns.count + 1 %}

                <div class="error-box">
                  <b>{{ server | escape }}:</b>
                  {{
                    result.error_message
                    | default('Unknown validation error')
                    | escape
                  }}
                </div>
                <br>
              {% endif %}
            {% endfor %}

            {% if error_ns.count == 0 %}
              <p class="no-updates">
                No playbook execution errors were detected.
              </p>
            {% endif %}
          </body>
          </html>

    - name: Confirm consolidated report is valid HTML
      ansible.builtin.shell: |
        set -euo pipefail
        test -s "{{ report_file }}"
        file "{{ report_file }}"
        grep -qi "<html" "{{ report_file }}"
        grep -qi "</html>" "{{ report_file }}"
      args:
        executable: /bin/bash
      changed_when: false

    - name: Send consolidated Windows post-patching report
      ansible.builtin.command:
        cmd: /usr/bin/python3
        stdin: |
          import smtplib
          from pathlib import Path
          from email.message import EmailMessage

          smtp_server = "{{ smtp_server }}"
          smtp_port = {{ smtp_port }}

          sender = "{{ email_from }}"
          recipient_value = "{{ patch_report_email }}"
          report = Path("{{ report_file }}")

          recipients = [
              address.strip()
              for address in recipient_value.replace(";", ",").split(",")
              if address.strip()
          ]

          if not recipients:
              raise ValueError(
                  "patch_report_email did not contain a valid recipient."
              )

          if not report.exists():
              raise FileNotFoundError(
                  f"Windows report was not found: {report}"
              )

          msg = EmailMessage()
          msg["From"] = sender
          msg["To"] = ", ".join(recipients)
          msg["Subject"] = (
              "Windows Post Patching Validation Report - "
              "{{ ansible_date_time.date }}"
          )

          html = """
          <!DOCTYPE html>
          <html>
          <body style="font-family: Arial, Helvetica, sans-serif;">
            <p>Hello,</p>

            <p>
              The Windows post-patching validation has completed.
            </p>

            <p>
              <b>Server Count:</b>
              {{ windows_report_servers | length }}
              <br>

              <b>Report Date:</b>
              {{ ansible_date_time.date }}
            </p>

            <p>
              The consolidated Windows HTML compliance report is attached.
            </p>

            <p>
              Automated Platform:<br>
              Ansible Automation Platform
            </p>
          </body>
          </html>
          """

          msg.set_content(
              "Windows post-patching validation completed. "
              "The consolidated HTML report is attached."
          )

          msg.add_alternative(
              html,
              subtype="html"
          )

          msg.add_attachment(
              report.read_bytes(),
              maintype="text",
              subtype="html",
              filename=report.name
          )

          with smtplib.SMTP(
              smtp_server,
              smtp_port,
              timeout=30
          ) as smtp:
              smtp.send_message(
                  msg,
                  from_addr=sender,
                  to_addrs=recipients
              )
      changed_when: true

    - name: Cleanup consolidated report from AAP execution node
      ansible.builtin.file:
        path: "{{ report_file }}"
        state: absent

Using Different Concurrency Levels for Each Patching Phase

The -f or --forks option sets the maximum number of hosts Ansible can process concurrently for an entire ansible-playbook run. It is not configured independently on ordinary individual tasks.

Because downloading, installing, and rebooting have different operational impacts, run each phase as a separate playbook or automation job with its own fork limit. You can also use serial or throttle when stricter batching is required.

Patching Phase Example Concurrency Reason
Download Updates ansible-playbook download-windows-updates.yml -f 100 Downloads primarily consume network bandwidth, so higher concurrency may be appropriate when sufficient capacity is available.
Install Updates ansible-playbook install-windows-updates.yml -f 30 Installation consumes CPU, memory, disk I/O, and Windows Update infrastructure capacity.
Reboot or Finalization serial: 10 or a lower job fork count Smaller batches reduce simultaneous outages, monitoring alerts, and service-recovery load.

Concurrency Controls

  • Forks: Set the maximum host concurrency for the complete playbook process using -f, --forks, or the automation platform's job-template setting.
  • Separate jobs: Give the download, installation, and finalization playbooks different fork limits.
  • serial: Divide hosts into controlled batches, such as rebooting 10 servers at a time.
  • throttle: Restrict the number of concurrent workers used by a particular task, block, or role without changing the overall job fork limit.
  • AAP workflows: Run separate job templates with different concurrency settings and connect them in the required execution order.
Important: throttle can lower concurrency for a task, but it cannot exceed the playbook's overall fork limit. For substantially different concurrency levels between phases, separate playbooks or automation jobs are usually the clearest design.

Establish Time Periods for a Pretend Environment

SQL Server cumulative updates and security updates do not follow a guaranteed quarterly schedule. Test Phase 3 whenever applicable SQL Server updates are approved and offered by your configured update source.

Knowing the above information, pretend we have 200 Windows servers in our environment. In my experience estimating 45 minutes per fork grouping will achieve accurate estimations for your server count. Let’s be conservative and use groups of 70 forks/servers to run update tasks simultaneously on at the end of a normal workday. 200 servers divided by three 45 minute windows is 2 hours and 15 minutes. Lets call it an even 2.5 hours for our Download phase. That equates to 2.5 hours of higher bandwidth usage before the maintenance window where we will plan to disrupt services and operation with reboots.

Let’s now schedule our 3 Phase Playbook Executions:

  1. 7:30 PM: download-windows-updates.yml -f 70 → 2.5 hours until the install task
  2. 10:00 PM (Maintenance Window): install-windows-updates.yml -f 70 → determine how long this task takes to complete
  3. 12:00 AM (Maintenance Window): finalize-windows-updates.yml -f 70 → execute this manually the first time so you know how long after the above task to schedule this one.

Adjustment Logic:
On an actual patching night, watch how long each task takes to complete. Maybe it finishes in less time or you still have plenty of bandwidth on your ISP connection to add more simultaneous connections. See if you can lengthen or condense your scheduled playbook execution times.

Review your reboot logic. Split your inventory into separate groups based on risk and criticality. Let stage 1 contain non-critical servers and stage 2 contain servers with critical functionality.


Forks vs. Job Slicing

If you determine that running against ~70 servers at once exceeds your available network bandwidth, you may need to adjust how your automation is executed.

Forks:
Forks control how many hosts Ansible attempts to run tasks against simultaneously within a single playbook run. If you're using Red Hat Ansible Automation Platform (AAP), check the Instance configuration for each node type.
Your control node may have a maximum fork limit (for example, 68), while your execution node may support a higher limit (for example, 168).
These limits determine which node should run your job based on the concurrency it can handle.

  • You may run with 68 forks on the control node
  • And run a second parallel job on an execution node using 168 forks, if needed

Job Slicing:
Job Slicing is used when you need to divide a large inventory into multiple, smaller, parallel playbook runs.
Instead of increasing concurrency (forks) within a single job, slicing breaks the workload into multiple distributed jobs.

How it works:

  • Enable slicing by setting job_slice_count on the Job Template (e.g., 3)
  • Example: 200 hosts sliced into 3 slices ≈ 67 hosts per slice
  • Each slice runs as an independent, parallel ansible-playbook run
  • Because slices are independent jobs, avoid slicing plays that require cross-host coordination or a strict global reboot order
  • AAP schedules slices across available cluster nodes for load balancing and better resource utilization
  • The result is a workflow-like execution, not a single monolithic job

When to Use Which?

Situation Use Forks Use Job Slicing
You want to tune concurrency inside a single job ✔️
You exceed controller/execution node fork limits ✔️
You have multiple nodes available and want to distribute load ✔️
Network bandwidth limits prevent high concurrency ✔️ (lower forks) ✔️ (smaller slices reduce per-job load)
You need faster total runtime across large inventories Maybe ✔️

Simple Guideline

  • Start by adjusting forks to match system and network capacity.
  • If forks alone are not enough — due to bandwidth, node limits, or cluster scaling — use job slicing to break the workload into parallel jobs.

Why strategy: free and -f?

  • strategy: free: Tasks run as fast as possible — no waiting for slowest host
  • -f 70: 70 parallel forks = 70 servers downloading/installing at once
  • Result: 70 servers patched in ~2 hours instead of 70+ hours

Final Thoughts

This three-phase, parallel, scheduled patching strategy gives you:

  • Speed with strategy: free and phase-specific job concurrency
  • Safety with no reboots until maintenance
  • Control with full visibility into every update
  • Reliability with retries, connection validation, structured results, and logging

This design has scaled reliably across hundreds of Windows servers while remaining simple enough to maintain with built-in Ansible modules alone. By separating downloads, operating system updates, SQL updates, and reporting into independent phases, you gain faster execution, easier troubleshooting, and predictable maintenance windows.


Tags: Ansible, Windows Patching, Automation, DevOps, Semaphore, AAP, strategy free, Ansible forks, serial, throttle, Azure Arc

Published: November 15, 2025
Author: Robert H. Osborne

🛸