Merge pull request #51004 from twangboy/win_wusa

Add tests for the win_wusa state and module
This commit is contained in:
Daniel Wozniak 2018-12-31 13:03:29 -07:00 committed by GitHub
commit cecd108aff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 614 additions and 89 deletions

View file

@ -6,20 +6,22 @@ Microsoft Update files management via wusa.exe
:platform: Windows
:depends: PowerShell
.. versionadded:: Neon
.. versionadded:: 2018.3.4
'''
# Import python libs
from __future__ import absolute_import, unicode_literals
import logging
import os
# Import salt libs
import salt.utils.platform
from salt.exceptions import CommandExecutionError
log = logging.getLogger(__name__)
# Define the module's virtual name
__virtualname__ = 'win_wusa'
__virtualname__ = 'wusa'
def __virtual__():
@ -36,58 +38,189 @@ def __virtual__():
return __virtualname__
def is_installed(kb):
def _pshell_json(cmd, cwd=None):
'''
Execute the desired powershell command and ensure that it returns data
in JSON format and load that into python
'''
if 'convertto-json' not in cmd.lower():
cmd = '{0} | ConvertTo-Json'.format(cmd)
log.debug('PowerShell: %s', cmd)
ret = __salt__['cmd.run_all'](cmd, shell='powershell', cwd=cwd)
if 'pid' in ret:
del ret['pid']
if ret.get('stderr', ''):
error = ret['stderr'].splitlines()[0]
raise CommandExecutionError(error, info=ret)
if 'retcode' not in ret or ret['retcode'] != 0:
# run_all logs an error to log.error, fail hard back to the user
raise CommandExecutionError(
'Issue executing PowerShell {0}'.format(cmd), info=ret)
# Sometimes Powershell returns an empty string, which isn't valid JSON
if ret['stdout'] == '':
ret['stdout'] = '{}'
try:
ret = salt.utils.json.loads(ret['stdout'], strict=False)
except ValueError:
raise CommandExecutionError(
'No JSON results from PowerShell', info=ret)
return ret
def is_installed(name):
'''
Check if a specific KB is installed.
Args:
name (str):
The name of the KB to check
Returns:
bool: ``True`` if installed, otherwise ``False``
CLI Example:
.. code-block:: bash
salt '*' win_wusa.is_installed KB123456
salt '*' wusa.is_installed KB123456
'''
get_hotfix_result = __salt__['cmd.powershell_all']('Get-HotFix -Id {0}'.format(kb), ignore_retcode=True)
return get_hotfix_result['retcode'] == 0
return __salt__['cmd.retcode'](cmd='Get-HotFix -Id {0}'.format(name),
shell='powershell',
ignore_retcode=True) == 0
def install(path):
def install(path, restart=False):
'''
Install a KB from a .msu file.
Some KBs will need a reboot, but this function does not manage it.
You may have to manage reboot yourself after installation.
Args:
path (str):
The full path to the msu file to install
restart (bool):
``True`` to force a restart if required by the installation. Adds
the ``/forcerestart`` switch to the ``wusa.exe`` command. ``False``
will add the ``/norestart`` switch instead. Default is ``False``
Returns:
bool: ``True`` if successful, otherwise ``False``
Raise:
CommandExecutionError: If the package is already installed or an error
is encountered
CLI Example:
.. code-block:: bash
salt '*' win_wusa.install C:/temp/KB123456.msu
salt '*' wusa.install C:/temp/KB123456.msu
'''
return __salt__['cmd.run_all']('wusa.exe {0} /quiet /norestart'.format(path), ignore_retcode=True)
# Build the command
cmd = ['wusa.exe', path, '/quiet']
if restart:
cmd.append('/forcerestart')
else:
cmd.append('/norestart')
# Run the command
ret_code = __salt__['cmd.retcode'](cmd, ignore_retcode=True)
# Check the ret_code
file_name = os.path.basename(path)
errors = {2359302: '{0} is already installed'.format(file_name),
87: 'Unknown error'}
if ret_code in errors:
raise CommandExecutionError(errors[ret_code])
elif ret_code:
raise CommandExecutionError('Unknown error: {0}'.format(ret_code))
return True
def uninstall(kb):
def uninstall(path, restart=False):
'''
Uninstall a specific KB.
CLI Example:
Args:
.. code-block:: bash
path (str):
The full path to the msu file to uninstall. This can also be just
the name of the KB to uninstall
salt '*' win_wusa.uninstall KB123456
'''
return __salt__['cmd.run_all']('wusa.exe /uninstall /kb:{0} /quiet /norestart'.format(kb[2:]), ignore_retcode=True)
restart (bool):
``True`` to force a restart if required by the installation. Adds
the ``/forcerestart`` switch to the ``wusa.exe`` command. ``False``
will add the ``/norestart`` switch instead. Default is ``False``
Returns:
bool: ``True`` if successful, otherwise ``False``
def list_kbs():
'''
Return a list of dictionaries, one dictionary for each installed KB.
The HotFixID key contains the ID of the KB.
Raises:
CommandExecutionError: If an error is encountered
CLI Example:
.. code-block:: bash
salt '*' win_wusa.list_kbs
salt '*' wusa.uninstall KB123456
# or
salt '*' wusa.uninstall C:/temp/KB123456.msu
'''
return __salt__['cmd.powershell']('Get-HotFix')
# Build the command
cmd = ['wusa.exe', '/uninstall', '/quiet']
kb = os.path.splitext(os.path.basename(path))[0]
if os.path.exists(path):
cmd.append(path)
else:
cmd.append(
'/kb:{0}'.format(kb[2:] if kb.lower().startswith('kb') else kb))
if restart:
cmd.append('/forcerestart')
else:
cmd.append('/norestart')
# Run the command
ret_code = __salt__['cmd.retcode'](cmd, ignore_retcode=True)
# Check the ret_code
# If you pass /quiet and specify /kb, you'll always get retcode 87 if there
# is an error. Use the actual file to get a more descriptive error
errors = {-2145116156: '{0} does not support uninstall'.format(kb),
2359303: '{0} not installed'.format(kb),
87: 'Unknown error. Try specifying an .msu file'}
if ret_code in errors:
raise CommandExecutionError(errors[ret_code])
elif ret_code:
raise CommandExecutionError('Unknown error: {0}'.format(ret_code))
return True
def list():
'''
Get a list of updates installed on the machine
Returns:
list: A list of installed updates
CLI Example:
.. code-block:: bash
salt '*' wusa.list
'''
kbs = []
ret = _pshell_json('Get-HotFix | Select HotFixID')
for item in ret:
kbs.append(item['HotFixID'])
return kbs

View file

@ -2,10 +2,10 @@
'''
Microsoft Updates (KB) Management
This module provides the ability to enforce KB installations
from files (.msu), without WSUS.
This module provides the ability to enforce KB installations from files (.msu),
without WSUS or Windows Update
.. versionadded:: Neon
.. versionadded:: 2018.3.4
'''
# Import python libs
@ -14,13 +14,14 @@ import logging
# Import salt libs
import salt.utils.platform
import salt.exceptions
import salt.utils.url
from salt.exceptions import SaltInvocationError
log = logging.getLogger(__name__)
# Define the module's virtual name
__virtualname__ = 'win_wusa'
__virtualname__ = 'wusa'
def __virtual__():
@ -35,81 +36,113 @@ def __virtual__():
def installed(name, source):
'''
Enforce the installed state of a KB
Ensure an update is installed on the minion
name
Name of the Windows KB ("KB123456")
source
Source of .msu file corresponding to the KB
Args:
name(str):
Name of the Windows KB ("KB123456")
source (str):
Source of .msu file corresponding to the KB
Example:
.. code-block:: yaml
KB123456:
wusa.installed:
- source: salt://kb123456.msu
'''
ret = {
'name': name,
'changes': {},
'result': False,
'comment': '',
}
ret = {'name': name,
'changes': {},
'result': False,
'comment': ''}
# Start with basic error-checking. Do all the passed parameters make sense
# and agree with each-other?
if not name or not source:
raise salt.exceptions.SaltInvocationError(
'Arguments "name" and "source" are mandatory.')
# Input validation
if not name:
raise SaltInvocationError('Must specify a KB "name"')
if not source:
raise SaltInvocationError('Must specify a "source" file to install')
# Check the current state of the system. Does anything need to change?
current_state = __salt__['win_wusa.is_installed'](name)
if current_state:
# Is the KB already installed
if __salt__['wusa.is_installed'](name):
ret['result'] = True
ret['comment'] = 'KB already installed'
ret['comment'] = '{0} already installed'.format(name)
return ret
# The state of the system does need to be changed. Check if we're running
# in ``test=true`` mode.
# Check for test=True
if __opts__['test'] is True:
ret['comment'] = 'The KB "{0}" will be installed.'.format(name)
ret['changes'] = {
'old': current_state,
'new': True,
}
# Return ``None`` when running with ``test=true``.
ret['result'] = None
ret['comment'] = '{0} would be installed'.format(name)
ret['result'] = None
return ret
try:
result = __states__['file.cached'](source,
skip_verify=True,
saltenv=__env__)
except Exception as exc:
msg = 'Failed to cache {0}: {1}'.format(
salt.utils.url.redact_http_basic_auth(source),
exc.__str__())
log.exception(msg)
# Cache the file
cached_source_path = __salt__['cp.cache_file'](path=source, saltenv=__env__)
if not cached_source_path:
msg = 'Unable to cache {0} from saltenv "{1}"'.format(
salt.utils.url.redact_http_basic_auth(source), __env__)
ret['comment'] = msg
return ret
if result['result']:
# Get the path of the file in the minion cache
cached = __salt__['cp.is_cached'](source, saltenv=__env__)
# Install the KB
__salt__['wusa.install'](cached_source_path)
# Verify successful install
if __salt__['wusa.is_installed'](name):
ret['comment'] = '{0} was installed'.format(name)
ret['changes'] = {'old': False, 'new': True}
ret['result'] = True
else:
log.debug(
'failed to download %s',
salt.utils.url.redact_http_basic_auth(source)
)
return result
# Finally, make the actual change and return the result.
new_state = __salt__['win_wusa.install'](cached)
ret['comment'] = 'The KB "{0}" was installed!'.format(name)
ret['changes'] = {
'old': current_state,
'new': new_state,
}
ret['result'] = True
ret['comment'] = '{0} failed to install'.format(name)
return ret
def uninstalled(name):
'''
Ensure an update is uninstalled from the minion
Args:
name(str):
Name of the Windows KB ("KB123456")
Example:
.. code-block:: yaml
KB123456:
wusa.uninstalled
'''
ret = {'name': name,
'changes': {},
'result': False,
'comment': ''}
# Is the KB already uninstalled
if not __salt__['wusa.is_installed'](name):
ret['result'] = True
ret['comment'] = '{0} already uninstalled'.format(name)
return ret
# Check for test=True
if __opts__['test'] is True:
ret['result'] = None
ret['comment'] = '{0} would be uninstalled'.format(name)
ret['result'] = None
return ret
# Uninstall the KB
__salt__['wusa.uninstall'](name)
# Verify successful uninstall
if not __salt__['wusa.is_installed'](name):
ret['comment'] = '{0} was uninstalled'.format(name)
ret['changes'] = {'old': True, 'new': False}
ret['result'] = True
else:
ret['comment'] = '{0} failed to uninstall'.format(name)
return ret

View file

@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
'''
Test the win_wusa execution module
'''
# Import Python Libs
from __future__ import absolute_import, unicode_literals, print_function
# Import Salt Testing Libs
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.mock import NO_MOCK, NO_MOCK_REASON, patch, MagicMock
from tests.support.unit import TestCase, skipIf
# Import Salt Libs
import salt.utils.platform
import salt.modules.win_wusa as win_wusa
from salt.exceptions import CommandExecutionError
@skipIf(NO_MOCK, NO_MOCK_REASON)
@skipIf(not salt.utils.platform.is_windows(), 'System is not Windows')
class WinWusaTestCase(TestCase, LoaderModuleMockMixin):
'''
test the functions in the win_wusa execution module
'''
def setup_loader_modules(self):
return {win_wusa: {}}
def test_is_installed_false(self):
'''
test is_installed function when the KB is not installed
'''
mock_retcode = MagicMock(return_value=1)
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
self.assertFalse(win_wusa.is_installed('KB123456'))
def test_is_installed_true(self):
'''
test is_installed function when the KB is installed
'''
mock_retcode = MagicMock(return_value=0)
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
self.assertTrue(win_wusa.is_installed('KB123456'))
def test_list(self):
'''
test list function
'''
ret = {'pid': 1,
'retcode': 0,
'stderr': '',
'stdout': '[{"HotFixID": "KB123456"}, '
'{"HotFixID": "KB123457"}]'}
mock_all = MagicMock(return_value=ret)
with patch.dict(win_wusa.__salt__, {'cmd.run_all': mock_all}):
expected = ['KB123456', 'KB123457']
returned = win_wusa.list()
self.assertListEqual(expected, returned)
def test_install(self):
'''
test install function
'''
mock_retcode = MagicMock(return_value=0)
path = 'C:\\KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
self.assertTrue(win_wusa.install(path))
mock_retcode.assert_called_once_with(
['wusa.exe', path, '/quiet', '/norestart'], ignore_retcode=True)
def test_install_restart(self):
'''
test install function with restart=True
'''
mock_retcode = MagicMock(return_value=0)
path = 'C:\\KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
self.assertTrue(win_wusa.install(path, restart=True))
mock_retcode.assert_called_once_with(
['wusa.exe', path, '/quiet', '/forcerestart'], ignore_retcode=True)
def test_install_already_installed(self):
'''
test install function when KB already installed
'''
mock_retcode = MagicMock(return_value=2359302)
path = 'C:\\KB123456.msu'
name = 'KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
with self.assertRaises(CommandExecutionError) as excinfo:
win_wusa.install(path)
mock_retcode.assert_called_once_with(
['wusa.exe', path, '/quiet', '/norestart'], ignore_retcode=True)
self.assertEqual('{0} is already installed'.format(name),
excinfo.exception.strerror)
def test_install_error_87(self):
'''
test install function when error 87 returned
'''
mock_retcode = MagicMock(return_value=87)
path = 'C:\\KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
with self.assertRaises(CommandExecutionError) as excinfo:
win_wusa.install(path)
mock_retcode.assert_called_once_with(
['wusa.exe', path, '/quiet', '/norestart'], ignore_retcode=True)
self.assertEqual('Unknown error', excinfo.exception.strerror)
def test_install_error_other(self):
'''
test install function on other unknown error
'''
mock_retcode = MagicMock(return_value=1234)
path = 'C:\\KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
with self.assertRaises(CommandExecutionError) as excinfo:
win_wusa.install(path)
mock_retcode.assert_called_once_with(
['wusa.exe', path, '/quiet', '/norestart'], ignore_retcode=True)
self.assertEqual('Unknown error: 1234', excinfo.exception.strerror)
def test_uninstall_kb(self):
'''
test uninstall function passing kb name
'''
mock_retcode = MagicMock(return_value=0)
kb = 'KB123456'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}), \
patch("os.path.exists", MagicMock(return_value=False)):
self.assertTrue(win_wusa.uninstall(kb))
mock_retcode.assert_called_once_with(
['wusa.exe', '/uninstall', '/quiet', '/kb:{0}'.format(kb[2:]), '/norestart'],
ignore_retcode=True)
def test_uninstall_path(self):
'''
test uninstall function passing full path to .msu file
'''
mock_retcode = MagicMock(return_value=0)
path = 'C:\\KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}), \
patch("os.path.exists", MagicMock(return_value=True)):
self.assertTrue(win_wusa.uninstall(path))
mock_retcode.assert_called_once_with(
['wusa.exe', '/uninstall', '/quiet', path, '/norestart'],
ignore_retcode=True)
def test_uninstall_path_restart(self):
'''
test uninstall function with full path and restart=True
'''
mock_retcode = MagicMock(return_value=0)
path = 'C:\\KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}), \
patch("os.path.exists", MagicMock(return_value=True)):
self.assertTrue(win_wusa.uninstall(path, restart=True))
mock_retcode.assert_called_once_with(
['wusa.exe', '/uninstall', '/quiet', path, '/forcerestart'],
ignore_retcode=True)
def test_uninstall_already_uninstalled(self):
'''
test uninstall function when KB already uninstalled
'''
mock_retcode = MagicMock(return_value=2359303)
kb = 'KB123456'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}):
with self.assertRaises(CommandExecutionError) as excinfo:
win_wusa.uninstall(kb)
mock_retcode.assert_called_once_with(
['wusa.exe', '/uninstall', '/quiet', '/kb:{0}'.format(kb[2:]), '/norestart'],
ignore_retcode=True)
self.assertEqual('{0} not installed'.format(kb),
excinfo.exception.strerror)
def test_uninstall_path_error_other(self):
'''
test uninstall function with unknown error
'''
mock_retcode = MagicMock(return_value=1234)
path = 'C:\\KB123456.msu'
with patch.dict(win_wusa.__salt__, {'cmd.retcode': mock_retcode}), \
patch("os.path.exists", MagicMock(return_value=True)), \
self.assertRaises(CommandExecutionError) as excinfo:
win_wusa.uninstall(path)
mock_retcode.assert_called_once_with(
['wusa.exe', '/uninstall', '/quiet', path, '/norestart'],
ignore_retcode=True)
self.assertEqual('Unknown error: 1234', excinfo.exception.strerror)

View file

@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
# Import Python libs
from __future__ import absolute_import, unicode_literals, print_function
# Import Salt Libs
import salt.states.win_wusa as wusa
from salt.exceptions import SaltInvocationError
# Import Salt Testing Libs
from tests.support.mixins import LoaderModuleMockMixin
from tests.support.unit import TestCase
from tests.support.mock import MagicMock, patch
class WinWusaTestCase(TestCase, LoaderModuleMockMixin):
'''
test the function in the win_wusa state module
'''
kb = 'KB123456'
def setup_loader_modules(self):
return {wusa: {'__opts__': {'test': False},
'__env__': 'base'}}
def test_installed_no_source(self):
'''
test wusa.installed without passing source
'''
with self.assertRaises(SaltInvocationError) as excinfo:
wusa.installed(name='KB123456', source=None)
self.assertEqual(excinfo.exception.strerror,
'Must specify a "source" file to install')
def test_installed_existing(self):
'''
test wusa.installed when the kb is already installed
'''
mock_installed = MagicMock(return_value=True)
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed}):
returned = wusa.installed(name=self.kb,
source='salt://{0}.msu'.format(self.kb))
expected = {'changes': {},
'comment': '{0} already installed'.format(self.kb),
'name': self.kb,
'result': True}
self.assertDictEqual(expected, returned)
def test_installed_test_true(self):
'''
test wusa.installed with test=True
'''
mock_installed = MagicMock(return_value=False)
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed}), \
patch.dict(wusa.__opts__, {'test': True}):
returned = wusa.installed(name=self.kb,
source='salt://{0}.msu'.format(self.kb))
expected = {'changes': {},
'comment': '{0} would be installed'.format(self.kb),
'name': self.kb,
'result': None}
self.assertDictEqual(expected, returned)
def test_installed_cache_fail(self):
'''
test wusa.install when it fails to cache the file
'''
mock_installed = MagicMock(return_value=False)
mock_cache = MagicMock(return_value='')
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed,
'cp.cache_file': mock_cache}):
returned = wusa.installed(name=self.kb,
source='salt://{0}.msu'.format(self.kb))
expected = {'changes': {},
'comment': 'Unable to cache salt://{0}.msu from '
'saltenv "base"'.format(self.kb),
'name': self.kb,
'result': False}
self.assertDictEqual(expected, returned)
def test_installed(self):
'''
test wusa.installed assuming success
'''
mock_installed = MagicMock(side_effect=[False, True])
mock_cache = MagicMock(return_value='C:\\{0}.msu'.format(self.kb))
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed,
'cp.cache_file': mock_cache,
'wusa.install': MagicMock()}):
returned = wusa.installed(name=self.kb,
source='salt://{0}.msu'.format(self.kb))
expected = {'changes': {'new': True, 'old': False},
'comment': '{0} was installed'.format(self.kb),
'name': self.kb,
'result': True}
self.assertDictEqual(expected, returned)
def test_installed_failed(self):
'''
test wusa.installed with a failure
'''
mock_installed = MagicMock(side_effect=[False, False])
mock_cache = MagicMock(return_value='C:\\{0}.msu'.format(self.kb))
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed,
'cp.cache_file': mock_cache,
'wusa.install': MagicMock()}):
returned = wusa.installed(name=self.kb,
source='salt://{0}.msu'.format(self.kb))
expected = {'changes': {},
'comment': '{0} failed to install'.format(self.kb),
'name': self.kb,
'result': False}
self.assertDictEqual(expected, returned)
def test_uninstalled_non_existing(self):
'''
test wusa.uninstalled when the kb is not installed
'''
mock_installed = MagicMock(return_value=False)
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed}):
returned = wusa.uninstalled(name=self.kb)
expected = {'changes': {},
'comment': '{0} already uninstalled'.format(self.kb),
'name': self.kb,
'result': True}
self.assertDictEqual(expected, returned)
def test_uninstalled_test_true(self):
'''
test wusa.uninstalled with test=True
'''
mock_installed = MagicMock(return_value=True)
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed}), \
patch.dict(wusa.__opts__, {'test': True}):
returned = wusa.uninstalled(name=self.kb)
expected = {'changes': {},
'comment': '{0} would be uninstalled'.format(self.kb),
'name': self.kb,
'result': None}
self.assertDictEqual(expected, returned)
def test_uninstalled(self):
'''
test wusa.uninstalled assuming success
'''
mock_installed = MagicMock(side_effect=[True, False])
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed,
'wusa.uninstall': MagicMock()}):
returned = wusa.uninstalled(name=self.kb)
expected = {'changes': {'new': False, 'old': True},
'comment': '{0} was uninstalled'.format(self.kb),
'name': self.kb,
'result': True}
self.assertDictEqual(expected, returned)
def test_uninstalled_failed(self):
'''
test wusa.uninstalled with a failure
'''
mock_installed = MagicMock(side_effect=[True, True])
with patch.dict(wusa.__salt__, {'wusa.is_installed': mock_installed,
'wusa.uninstall': MagicMock()}):
returned = wusa.uninstalled(name=self.kb)
expected = {'changes': {},
'comment': '{0} failed to uninstall'.format(self.kb),
'name': self.kb,
'result': False}
self.assertDictEqual(expected, returned)