--- - name: Windows Update Installation from Assessment Report hosts: windows gather_facts: no tasks: - name: Get current timestamp set_fact: current_timestamp: "{{ lookup('pipe', 'date +%Y-%m-%dT%H:%M:%S') }}" - name: Check if KB updates report file exists win_stat: path: 'C:\Temp\windows_updates_with_kb.txt' register: kb_updates_file - name: Fail if updates report file is missing fail: msg: 'KB updates report file not found at C:\Temp\windows_updates_with_kb.txt. Please run the assessment playbook first.' when: not kb_updates_file.stat.exists - name: Read KB updates report content win_shell: Get-Content -Path 'C:\Temp\windows_updates_with_kb.txt' register: updates_content when: kb_updates_file.stat.exists - name: Extract KB numbers from report file set_fact: kb_numbers: "{{ updates_content.stdout_lines | select('match', '.*KB: .*') | map('regex_replace', '.*KB: ([0-9,\\s]+).*', '\\1') | map('split', ',') | flatten | map('trim') | select('match', '^[0-9]+$') | list | unique }}" when: - kb_updates_file.stat.exists - updates_content.stdout_lines is defined - name: Display KB numbers to be installed debug: msg: - "Found {{ kb_numbers | length }} unique KB numbers to install:" - "{{ kb_numbers | join(', ') }}" when: - kb_updates_file.stat.exists - kb_numbers is defined - kb_numbers | length > 0 - name: Install Windows updates by KB numbers win_updates: category_names: '*' state: installed accept_list: "{{ kb_numbers }}" log_path: 'C:\Temp\windows_update_installation.log' register: installation_result when: - kb_updates_file.stat.exists - kb_numbers is defined - kb_numbers | length > 0 - name: Mark host failed if any KB installs failed set_fact: patch_failed_host: "{{ (installation_result.failed_update_count | default(0) | int) > 0 }}" patch_failed_count: "{{ installation_result.failed_update_count | default(0) | int }}" when: - kb_updates_file.stat.exists - kb_numbers is defined - kb_numbers | length > 0 - installation_result is defined - name: Display installation summary debug: msg: - "=== WINDOWS UPDATE INSTALLATION COMPLETE ===" - "Host: {{ inventory_hostname }}" - "Updates Found: {{ installation_result.found_update_count | default(0) }}" - "Updates Installed: {{ installation_result.installed_update_count | default(0) }}" - "Updates Failed: {{ installation_result.failed_update_count | default(0) }}" - "Reboot Required: {{ 'Yes' if installation_result.reboot_required | default(false) else 'No' }}" when: - kb_updates_file.stat.exists - kb_numbers is defined - kb_numbers | length > 0 - installation_result is defined - name: Reboot if required win_reboot: reboot_timeout: 1800 when: installation_result.reboot_required | default(false) - name: Create installation report set_fact: installation_summary: | Windows Update Installation Report ================================= Host: {{ inventory_hostname }} Date: {{ current_timestamp }} Summary: -------- Total Updates Found: {{ installation_result.found_update_count | default(0) }} Successfully Installed: {{ installation_result.installed_update_count | default(0) }} Failed Installations: {{ installation_result.failed_update_count | default(0) }} Reboot Required: {{ installation_result.reboot_required | default('No') }} Requested KB Numbers: {{ kb_numbers | join(', ') }} {% if installation_result.updates is defined %} Installed Updates: ----------------- {% for update_id, update_info in installation_result.updates.items() %} - {{ update_info.title }} KB: {{ update_info.kb | join(', ') if update_info.kb else 'None' }} {% endfor %} {% endif %} when: - kb_updates_file.stat.exists - kb_numbers is defined - kb_numbers | length > 0 - installation_result is defined - name: Save installation report to file win_copy: content: "{{ installation_summary }}" dest: 'C:\Temp\windows_update_installation_report.txt' when: - kb_updates_file.stat.exists - kb_numbers is defined - kb_numbers | length > 0 - installation_result is defined - installation_summary is defined - name: Give a report when no KB numbers were found on updates debug: msg: "No valid KB numbers found in the updates report file. Please verify the assessment report." when: - kb_updates_file.stat.exists - (kb_numbers is not defined or kb_numbers | length == 0) # ------------------------------------------------------------------------------ - name: Post patching results to SharePoint (Graph) hosts: windows gather_facts: false vars: tenant_id: "{{ lookup('env', 'SP_TENANT_ID') }}" client_id: "{{ lookup('env', 'SP_CLIENT_ID') }}" client_secret: "{{ lookup('env', 'SP_CLIENT_SECRET') }}" site_id: "{{ lookup('env', 'SP_SITE_ID') }}" list_id: "{{ lookup('env', 'SP_LIST_ID') }}" # Helpful AWX vars (exist in AWX/Controller job context) job_id: "{{ tower_job_id | default('n/a') }}" job_name: "{{ tower_job_template_name | default('Patch run') }}" job_url: "{{ tower_job_url | default('') }}" # Timestamps (works without gather_facts) run_start: "{{ lookup('pipe','date -u +%Y-%m-%dT%H:%M:%SZ') }}" run_end: "{{ lookup('pipe','date -u +%Y-%m-%dT%H:%M:%SZ') }}" summary_text: >- Job {{ job_id }}. Template={{ job_name }}. URL={{ job_url }}. tasks: - name: Acquire Graph token (client credentials) delegate_to: localhost run_once: true uri: url: "https://login.microsoftonline.com/{{ tenant_id }}/oauth2/v2.0/token" method: POST headers: Content-Type: "application/x-www-form-urlencoded" body: > client_id={{ client_id }} &client_secret={{ client_secret | urlencode }} &scope=https%3A%2F%2Fgraph.microsoft.com%2F.default &grant_type=client_credentials register: graph_token no_log: true failed_when: graph_token.status not in [200] # --- AWX recap (controller's tallies) --- - name: Fetch play recap (playbook_on_stats) delegate_to: localhost run_once: true uri: url: "{{ lookup('env','AWX_API_URL') }}/api/v2/jobs/{{ tower_job_id }}/job_events/?event=playbook_on_stats" method: GET headers: Authorization: "Bearer {{ lookup('env','AWX_API_TOKEN') }}" Content-Type: "application/json" return_content: true status_code: 200 register: _recap no_log: true - name: Parse recap totals run_once: true vars: stats: "{{ (_recap.json.results | default([])) | first | default({}) }}" data: "{{ stats.event_data | default({}) }}" set_fact: recap_ok: "{{ (data.ok | default({})) | dict2items | map(attribute='value') | map('int') | sum }}" recap_changed: "{{ (data.changed | default({})) | dict2items | map(attribute='value') | map('int') | sum }}" recap_failed: "{{ (data.failures | default({})) | dict2items | map(attribute='value') | map('int') | sum }}" recap_skipped: "{{ (data.skipped | default({})) | dict2items | map(attribute='value') | map('int') | sum }}" recap_unreach: "{{ (data.dark | default({})) | dict2items | map(attribute='value') | map('int') | sum }}" - name: Build SharePoint recap line (store on localhost for later use) run_once: true delegate_to: localhost delegate_facts: true set_fact: recap_line: >- OK={{ recap_ok | default(0) }}, Changed={{ recap_changed | default(0) }}, Failed={{ recap_failed | default(0) }}, Unreachable={{ recap_unreach | default(0) }} # --- Aggregate per-host patch failures set in the first play --- - name: Aggregate per-host patch failures run_once: true delegate_to: localhost vars: flags: "{{ ansible_play_hosts_all | map('extract', hostvars, 'patch_failed_host') | map('default', false) | list }}" set_fact: any_patch_failed: "{{ (flags | select('equalto', true) | list | length) > 0 }}" - name: Build final status from recap + per-host flags run_once: true set_fact: status_final: >- {{ 'failed' if (any_patch_failed | default(false)) or (recap_failed | int > 0) or (recap_unreach | int > 0) else 'successful' }} # --- Create list item (Graph) --- - name: Create SharePoint list item (Graph) delegate_to: localhost run_once: true uri: url: "https://graph.microsoft.com/v1.0/sites/{{ site_id }}/lists/{{ list_id }}/items" method: POST headers: Authorization: "Bearer {{ graph_token.json.access_token }}" Content-Type: "application/json" body_format: json return_content: true status_code: [200, 201] body: fields: Title: "{{ job_name }} ({{ job_id }})" Status: "{{ status_final }}" RunStart: "{{ run_start }}" RunEnd: "{{ run_end }}" Notes: |- {{ summary_text }} Recap: {{ hostvars['localhost'].recap_line }} Failed hosts: {% set first=true -%} {%- for h in ansible_play_hosts_all -%} {%- if hostvars[h].patch_failed_host | default(false) -%} {{ '' if first else ', ' }}{{ h }}{% set first=false -%} {%- endif -%} {%- endfor -%} {%- if first -%}None{%- endif -%} register: sp_create ignore_errors: true no_log: true - name: Show sanitized Graph error (if any) run_once: true when: sp_create is failed vars: _json: "{{ sp_create.json | default({}) }}" debug: msg: status: "{{ sp_create.status | default('n/a') }}" graph_error: >- {{ _json.error.message | default(_json.message | default(sp_create.msg | default('Unknown error'))) }} hint: > 400: column internal names; 401: scope/audience; 403: permissions; 404: siteId/listId. - name: Fail if SharePoint item was not created run_once: true when: sp_create is failed fail: msg: "Failed to create SharePoint item (see previous message)." - name: Show created list item id run_once: true when: sp_create is succeeded debug: var: sp_create.json.id