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:
Download Updates:
Search for applicable updates, filter known phantom update GUIDs, and download the selected updates without rebooting.
Install Windows Updates and Reboot:
Install operating system, security, cumulative, .NET, and other approved updates before rebooting and verifying connectivity.
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.
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 = '
]*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.
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.
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.
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.
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:
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.
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.
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:
7:30 PM: download-windows-updates.yml -f 70 → 2.5 hours until the install task
10:00 PM (Maintenance Window): install-windows-updates.yml -f 70 → determine how long this task takes to complete
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.