mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-10-24 04:54:00 -07:00
win_updates: action plugin-ify it (#33216)
* converted win_updates to an action plugin for automatic reboots * do not set final result when running under async * Updated documentation around win_updates with async and become
This commit is contained in:
parent
08957cf46e
commit
557716dc49
4 changed files with 315 additions and 24 deletions
|
|
@ -267,15 +267,15 @@ foreach ($update in $updates_to_install) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($update_fail_count -gt 0) {
|
|
||||||
Fail-Json -obj $result -msg "Failed to install one or more updates"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-DebugLog -msg "Performing post-install reboot requirement check..."
|
Write-DebugLog -msg "Performing post-install reboot requirement check..."
|
||||||
$result.reboot_required = Get-RebootStatus
|
$result.reboot_required = Get-RebootStatus
|
||||||
$result.installed_update_count = $update_success_count
|
$result.installed_update_count = $update_success_count
|
||||||
$result.failed_update_count = $update_fail_count
|
$result.failed_update_count = $update_fail_count
|
||||||
|
|
||||||
|
if ($update_fail_count -gt 0) {
|
||||||
|
Fail-Json -obj $result -msg "Failed to install one or more updates"
|
||||||
|
}
|
||||||
|
|
||||||
Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)"
|
Write-DebugLog -msg "Return value:`r`n$(ConvertTo-Json -InputObject $result -Depth 99)"
|
||||||
|
|
||||||
Exit-Json $result
|
Exit-Json $result
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,9 @@
|
||||||
#!/usr/bin/python
|
#!/usr/bin/python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
# (c) 2015, Matt Davis <mdavis_ansible@rolpdog.com>
|
# Copyright (c) 2015, Matt Davis <mdavis_ansible@rolpdog.com>
|
||||||
#
|
# Copyright (c) 2017 Ansible Project
|
||||||
# This file is part of Ansible
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
#
|
|
||||||
# Ansible is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# Ansible is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
# this is a windows documentation stub. actual code lives in the .ps1
|
# this is a windows documentation stub. actual code lives in the .ps1
|
||||||
# file of the same name
|
# file of the same name
|
||||||
|
|
@ -52,6 +39,23 @@ options:
|
||||||
- Tools
|
- Tools
|
||||||
- UpdateRollups
|
- UpdateRollups
|
||||||
- Updates
|
- Updates
|
||||||
|
reboot:
|
||||||
|
description:
|
||||||
|
- Ansible will automatically reboot the remote host if it is required
|
||||||
|
and continue to install updates after the reboot.
|
||||||
|
- This can be used instead of using a M(win_reboot) task after this one
|
||||||
|
and ensures all updates for that category is installed in one go.
|
||||||
|
- Async does not work when C(reboot=True).
|
||||||
|
type: bool
|
||||||
|
default: 'no'
|
||||||
|
version_added: '2.5'
|
||||||
|
reboot_timeout:
|
||||||
|
description:
|
||||||
|
- The time in seconds to wait until the host is back online from a
|
||||||
|
reboot.
|
||||||
|
- This is only used if C(reboot=True) and a reboot is required.
|
||||||
|
default: 1200
|
||||||
|
version_added: '2.5'
|
||||||
state:
|
state:
|
||||||
description:
|
description:
|
||||||
- Controls whether found updates are returned as a list or actually installed.
|
- Controls whether found updates are returned as a list or actually installed.
|
||||||
|
|
@ -67,9 +71,11 @@ options:
|
||||||
required: false
|
required: false
|
||||||
author: "Matt Davis (@nitzmahone)"
|
author: "Matt Davis (@nitzmahone)"
|
||||||
notes:
|
notes:
|
||||||
- C(win_updates) must be run by a user with membership in the local Administrators group
|
- C(win_updates) must be run by a user with membership in the local Administrators group.
|
||||||
- C(win_updates) will use the default update service configured for the machine (Windows Update, Microsoft Update, WSUS, etc)
|
- C(win_updates) will use the default update service configured for the machine (Windows Update, Microsoft Update, WSUS, etc).
|
||||||
- C(win_updates) does not manage reboots, but will signal when a reboot is required with the reboot_required return value.
|
- By default C(win_updates) does not manage reboots, but will signal when a
|
||||||
|
reboot is required with the I(reboot_required) return value, as of Ansible 2.5
|
||||||
|
C(reboot) can be used to reboot the host if required in the one task.
|
||||||
- C(win_updates) can take a significant amount of time to complete (hours, in some cases).
|
- C(win_updates) can take a significant amount of time to complete (hours, in some cases).
|
||||||
Performance depends on many factors, including OS version, number of updates, system load, and update server load.
|
Performance depends on many factors, including OS version, number of updates, system load, and update server load.
|
||||||
'''
|
'''
|
||||||
|
|
@ -91,6 +97,43 @@ EXAMPLES = r'''
|
||||||
category_names: SecurityUpdates
|
category_names: SecurityUpdates
|
||||||
state: searched
|
state: searched
|
||||||
log_path: c:\ansible_wu.txt
|
log_path: c:\ansible_wu.txt
|
||||||
|
|
||||||
|
- name: Install all security updates with automatic reboots
|
||||||
|
win_updates:
|
||||||
|
category_names:
|
||||||
|
- SecurityUpdates
|
||||||
|
reboot: yes
|
||||||
|
|
||||||
|
# Note async on works on Windows Server 2012 or newer - become must be explicitly set on the task for this to work
|
||||||
|
- name: Search for Windows updates asynchronously
|
||||||
|
win_updates:
|
||||||
|
category_names:
|
||||||
|
- SecurityUpdates
|
||||||
|
state: searched
|
||||||
|
async: 180
|
||||||
|
poll: 10
|
||||||
|
register: updates_to_install
|
||||||
|
become: yes
|
||||||
|
become_method: runas
|
||||||
|
become_user: SYSTEM
|
||||||
|
|
||||||
|
# Async can also be run in the background in a fire and forget fashion
|
||||||
|
- name: Search for Windows updates asynchronously (poll and forget)
|
||||||
|
win_updates:
|
||||||
|
category_names:
|
||||||
|
- SecurityUpdates
|
||||||
|
state: searched
|
||||||
|
async: 180
|
||||||
|
poll: 0
|
||||||
|
register: updates_to_install_async
|
||||||
|
|
||||||
|
- name: get status of Windows Update async job
|
||||||
|
async_status:
|
||||||
|
jid: '{{ updates_to_install_async.ansible_job_id }}'
|
||||||
|
register: updates_to_install_result
|
||||||
|
become: yes
|
||||||
|
become_method: runas
|
||||||
|
become_user: SYSTEM
|
||||||
'''
|
'''
|
||||||
|
|
||||||
RETURN = r'''
|
RETURN = r'''
|
||||||
|
|
|
||||||
248
lib/ansible/plugins/action/win_updates.py
Normal file
248
lib/ansible/plugins/action/win_updates.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
from __future__ import (absolute_import, division, print_function)
|
||||||
|
__metaclass__ = type
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ansible.errors import AnsibleError
|
||||||
|
from ansible.module_utils._text import to_text
|
||||||
|
from ansible.module_utils.parsing.convert_bool import boolean
|
||||||
|
from ansible.plugins.action import ActionBase
|
||||||
|
|
||||||
|
try:
|
||||||
|
from __main__ import display
|
||||||
|
except ImportError:
|
||||||
|
from ansible.utils.display import Display
|
||||||
|
display = Display()
|
||||||
|
|
||||||
|
|
||||||
|
class ActionModule(ActionBase):
|
||||||
|
|
||||||
|
DEFAULT_REBOOT_TIMEOUT = 1200
|
||||||
|
|
||||||
|
def _validate_categories(self, category_names):
|
||||||
|
valid_categories = [
|
||||||
|
'Application',
|
||||||
|
'Connectors',
|
||||||
|
'CriticalUpdates',
|
||||||
|
'DefinitionUpdates',
|
||||||
|
'DeveloperKits',
|
||||||
|
'FeaturePacks',
|
||||||
|
'Guidance',
|
||||||
|
'SecurityUpdates',
|
||||||
|
'ServicePacks',
|
||||||
|
'Tools',
|
||||||
|
'UpdateRollups',
|
||||||
|
'Updates'
|
||||||
|
]
|
||||||
|
for name in category_names:
|
||||||
|
if name not in valid_categories:
|
||||||
|
raise AnsibleError("Unknown category_name %s, must be one of "
|
||||||
|
"(%s)" % (name, ','.join(valid_categories)))
|
||||||
|
|
||||||
|
def _run_win_updates(self, module_args, task_vars):
|
||||||
|
display.vvv("win_updates: running win_updates module")
|
||||||
|
result = self._execute_module(module_name='win_updates',
|
||||||
|
module_args=module_args,
|
||||||
|
task_vars=task_vars,
|
||||||
|
wrap_async=self._task.async_val)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _reboot_server(self, task_vars, reboot_timeout):
|
||||||
|
display.vvv("win_updates: rebooting remote host after update install")
|
||||||
|
reboot_args = {
|
||||||
|
'reboot_timeout': reboot_timeout
|
||||||
|
}
|
||||||
|
reboot_result = self._run_action_plugin('win_reboot', task_vars,
|
||||||
|
module_args=reboot_args)
|
||||||
|
if reboot_result.get('failed', False):
|
||||||
|
raise AnsibleError(reboot_result['msg'])
|
||||||
|
|
||||||
|
display.vvv("win_updates: checking WUA is not busy with win_shell "
|
||||||
|
"command")
|
||||||
|
# While this always returns False after a reboot it doesn't return a
|
||||||
|
# value until Windows is actually ready and finished installing updates
|
||||||
|
# This needs to run with become as WUA doesn't work over WinRM
|
||||||
|
# Ignore connection errors as another reboot can happen
|
||||||
|
command = "(New-Object -ComObject Microsoft.Update.Session)." \
|
||||||
|
"CreateUpdateInstaller().IsBusy"
|
||||||
|
shell_module_args = {
|
||||||
|
'_raw_params': command
|
||||||
|
}
|
||||||
|
|
||||||
|
# run win_shell module with become and ignore any errors in case of
|
||||||
|
# a windows reboot during execution
|
||||||
|
orig_become = self._play_context.become
|
||||||
|
orig_become_method = self._play_context.become_method
|
||||||
|
orig_become_user = self._play_context.become_user
|
||||||
|
if orig_become is None or orig_become is False:
|
||||||
|
self._play_context.become = True
|
||||||
|
if orig_become_method != 'runas':
|
||||||
|
self._play_context.become_method = 'runas'
|
||||||
|
if orig_become_user is None or 'root':
|
||||||
|
self._play_context.become_user = 'SYSTEM'
|
||||||
|
try:
|
||||||
|
shell_result = self._execute_module(module_name='win_shell',
|
||||||
|
module_args=shell_module_args,
|
||||||
|
task_vars=task_vars)
|
||||||
|
display.vvv("win_updates: shell wait results: %s"
|
||||||
|
% json.dumps(shell_result))
|
||||||
|
except Exception as exc:
|
||||||
|
display.debug("win_updates: Fatal error when running shell "
|
||||||
|
"command, attempting to recover: %s" % to_text(exc))
|
||||||
|
finally:
|
||||||
|
self._play_context.become = orig_become
|
||||||
|
self._play_context.become_method = orig_become_method
|
||||||
|
self._play_context.become_user = orig_become_user
|
||||||
|
|
||||||
|
display.vvv("win_updates: ensure the connection is up and running")
|
||||||
|
# in case Windows needs to reboot again after the updates, we wait for
|
||||||
|
# the connection to be stable again
|
||||||
|
wait_for_result = self._run_action_plugin('wait_for_connection',
|
||||||
|
task_vars)
|
||||||
|
if wait_for_result.get('failed', False):
|
||||||
|
raise AnsibleError(wait_for_result['msg'])
|
||||||
|
|
||||||
|
def _run_action_plugin(self, plugin_name, task_vars, module_args=None):
|
||||||
|
# Create new task object and reset the args
|
||||||
|
new_task = self._task.copy()
|
||||||
|
new_task.args = {}
|
||||||
|
|
||||||
|
if module_args is not None:
|
||||||
|
for key, value in module_args.items():
|
||||||
|
new_task.args[key] = value
|
||||||
|
|
||||||
|
# run the action plugin and return the results
|
||||||
|
action = self._shared_loader_obj.action_loader.get(
|
||||||
|
plugin_name,
|
||||||
|
task=new_task,
|
||||||
|
connection=self._connection,
|
||||||
|
play_context=self._play_context,
|
||||||
|
loader=self._loader,
|
||||||
|
templar=self._templar,
|
||||||
|
shared_loader_obj=self._shared_loader_obj
|
||||||
|
)
|
||||||
|
return action.run(task_vars=task_vars)
|
||||||
|
|
||||||
|
def _merge_dict(self, original, new):
|
||||||
|
dict_var = original.copy()
|
||||||
|
dict_var.update(new)
|
||||||
|
return dict_var
|
||||||
|
|
||||||
|
def run(self, tmp=None, task_vars=None):
|
||||||
|
self._supports_check_mode = True
|
||||||
|
self._supports_async = True
|
||||||
|
|
||||||
|
result = super(ActionModule, self).run(tmp, task_vars)
|
||||||
|
|
||||||
|
category_names = self._task.args.get('category_names', [
|
||||||
|
'CriticalUpdates',
|
||||||
|
'SecurityUpdates',
|
||||||
|
'UpdateRollups',
|
||||||
|
])
|
||||||
|
state = self._task.args.get('state', 'installed')
|
||||||
|
reboot = self._task.args.get('reboot', False)
|
||||||
|
reboot_timeout = self._task.args.get('reboot_timeout',
|
||||||
|
self.DEFAULT_REBOOT_TIMEOUT)
|
||||||
|
|
||||||
|
# Validate the options
|
||||||
|
try:
|
||||||
|
self._validate_categories(category_names)
|
||||||
|
except AnsibleError as exc:
|
||||||
|
result['failed'] = True
|
||||||
|
result['msg'] = to_text(exc)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if state not in ['installed', 'searched']:
|
||||||
|
result['failed'] = True
|
||||||
|
result['msg'] = "state must be either installed or searched"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
reboot = boolean(reboot)
|
||||||
|
except TypeError as exc:
|
||||||
|
result['failed'] = True
|
||||||
|
result['msg'] = "cannot parse reboot as a boolean: %s" % to_text(exc)
|
||||||
|
return result
|
||||||
|
|
||||||
|
if not isinstance(reboot_timeout, int):
|
||||||
|
result['failed'] = True
|
||||||
|
result['msg'] = "reboot_timeout must be an integer"
|
||||||
|
return result
|
||||||
|
|
||||||
|
if reboot and self._task.async_val > 0:
|
||||||
|
result['failed'] = True
|
||||||
|
result['msg'] = "async is not supported for this task when " \
|
||||||
|
"reboot=yes"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Run the module
|
||||||
|
new_module_args = self._task.args.copy()
|
||||||
|
new_module_args.pop('reboot', None)
|
||||||
|
new_module_args.pop('reboot_timeout', None)
|
||||||
|
result = self._run_win_updates(new_module_args, task_vars)
|
||||||
|
|
||||||
|
changed = result['changed']
|
||||||
|
updates = result.get('updates', dict())
|
||||||
|
found_update_count = result.get('found_update_count', 0)
|
||||||
|
installed_update_count = result.get('installed_update_count', 0)
|
||||||
|
|
||||||
|
# Handle automatic reboots if the reboot flag is set
|
||||||
|
if reboot and state == 'installed' and not \
|
||||||
|
self._play_context.check_mode:
|
||||||
|
previously_errored = False
|
||||||
|
while result['installed_update_count'] > 0 or \
|
||||||
|
result['found_update_count'] > 0 or \
|
||||||
|
result['reboot_required'] is True:
|
||||||
|
display.vvv("win_updates: check win_updates results for "
|
||||||
|
"automatic reboot: %s" % json.dumps(result))
|
||||||
|
|
||||||
|
# check if the module failed, break from the loop if it
|
||||||
|
# previously failed and return error to the user
|
||||||
|
if result.get('failed', False):
|
||||||
|
if previously_errored:
|
||||||
|
break
|
||||||
|
previously_errored = True
|
||||||
|
else:
|
||||||
|
previously_errored = False
|
||||||
|
|
||||||
|
reboot_error = None
|
||||||
|
# check if a reboot was required before installing the updates
|
||||||
|
if result.get('msg', '') == "A reboot is required before " \
|
||||||
|
"more updates can be installed":
|
||||||
|
reboot_error = "reboot was required before more updates " \
|
||||||
|
"can be installed"
|
||||||
|
|
||||||
|
if result.get('reboot_required', False):
|
||||||
|
if reboot_error is None:
|
||||||
|
reboot_error = "reboot was required to finalise " \
|
||||||
|
"update install"
|
||||||
|
try:
|
||||||
|
changed = True
|
||||||
|
self._reboot_server(task_vars, reboot_timeout)
|
||||||
|
except AnsibleError as exc:
|
||||||
|
result['failed'] = True
|
||||||
|
result['msg'] = "Failed to reboot remote host when " \
|
||||||
|
"%s: %s" \
|
||||||
|
% (reboot_error, to_text(exc))
|
||||||
|
break
|
||||||
|
|
||||||
|
result.pop('msg', None)
|
||||||
|
# rerun the win_updates module after the reboot is complete
|
||||||
|
result = self._run_win_updates(new_module_args, task_vars)
|
||||||
|
|
||||||
|
result_updates = result.get('updates', dict())
|
||||||
|
updates = self._merge_dict(updates, result_updates)
|
||||||
|
found_update_count += result.get('found_update_count', 0)
|
||||||
|
installed_update_count += result.get('installed_update_count', 0)
|
||||||
|
if result['changed']:
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# finally create the return dict based on the aggregated execution
|
||||||
|
# values if we are not in async
|
||||||
|
if self._task.async_val == 0:
|
||||||
|
result['changed'] = changed
|
||||||
|
result['updates'] = updates
|
||||||
|
result['found_update_count'] = found_update_count
|
||||||
|
result['installed_update_count'] = installed_update_count
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
win_updates:
|
win_updates:
|
||||||
state: invalid
|
state: invalid
|
||||||
register: invalid_state
|
register: invalid_state
|
||||||
failed_when: "invalid_state.msg != 'Get-AnsibleParam: Argument state needs to be one of installed,searched but was invalid.'"
|
failed_when: invalid_state.msg != 'state must be either installed or searched'
|
||||||
|
|
||||||
- name: expect failure with invalid category name
|
- name: expect failure with invalid category name
|
||||||
win_updates:
|
win_updates:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue