File: //usr/lib/python2.7/site-packages/salt/modules/mac_brew_pkg.py
# -*- coding: utf-8 -*-
'''
Homebrew for macOS
.. important::
If you feel that Salt should be using this module to manage packages on a
minion, and it is using a different module (or gives an error similar to
*'pkg.install' is not available*), see :ref:`here
<module-provider-override>`.
'''
from __future__ import absolute_import, print_function, unicode_literals
# Import python libs
import copy
import functools
import logging
import re
# Import salt libs
import salt.utils.data
import salt.utils.functools
import salt.utils.json
import salt.utils.path
import salt.utils.pkg
import salt.utils.versions
from salt.exceptions import CommandExecutionError, MinionError
# Import third party libs
from salt.ext import six
from salt.ext.six.moves import zip
log = logging.getLogger(__name__)
# Define the module's virtual name
__virtualname__ = 'pkg'
def __virtual__():
'''
Confine this module to Mac OS with Homebrew.
'''
if salt.utils.path.which('brew') and __grains__['os'] == 'MacOS':
return __virtualname__
return (False, 'The brew module could not be loaded: brew not found or grain os != MacOS')
def _list_taps():
'''
List currently installed brew taps
'''
cmd = 'tap'
return _call_brew(cmd)['stdout'].splitlines()
def _tap(tap, runas=None):
'''
Add unofficial GitHub repos to the list of formulas that brew tracks,
updates, and installs from.
'''
if tap in _list_taps():
return True
cmd = 'tap {0}'.format(tap)
try:
_call_brew(cmd)
except CommandExecutionError:
log.error('Failed to tap "%s"', tap)
return False
return True
def _homebrew_bin():
'''
Returns the full path to the homebrew binary in the PATH
'''
ret = __salt__['cmd.run']('brew --prefix', output_loglevel='trace')
ret += '/bin/brew'
return ret
def _call_brew(cmd, failhard=True):
'''
Calls the brew command with the user account of brew
'''
user = __salt__['file.get_user'](_homebrew_bin())
runas = user if user != __opts__['user'] else None
cmd = '{} {}'.format(salt.utils.path.which('brew'), cmd)
result = __salt__['cmd.run_all'](cmd,
runas=runas,
output_loglevel='trace',
python_shell=False)
if failhard and result['retcode'] != 0:
raise CommandExecutionError('Brew command failed',
info={'result': result})
return result
def list_pkgs(versions_as_list=False, **kwargs):
'''
List the packages currently installed in a dict::
{'<package_name>': '<version>'}
CLI Example:
.. code-block:: bash
salt '*' pkg.list_pkgs
'''
versions_as_list = salt.utils.data.is_true(versions_as_list)
# not yet implemented or not applicable
if any([salt.utils.data.is_true(kwargs.get(x))
for x in ('removed', 'purge_desired')]):
return {}
if 'pkg.list_pkgs' in __context__:
if versions_as_list:
return __context__['pkg.list_pkgs']
else:
ret = copy.deepcopy(__context__['pkg.list_pkgs'])
__salt__['pkg_resource.stringify'](ret)
return ret
ret = {}
cmd = 'info --json=v1 --installed'
package_info = salt.utils.json.loads(_call_brew(cmd)['stdout'])
for package in package_info:
# Brew allows multiple versions of the same package to be installed.
# Salt allows for this, so it must be accounted for.
versions = [v['version'] for v in package['installed']]
# Brew allows for aliasing of packages, all of which will be
# installable from a Salt call, so all names must be accounted for.
names = package['aliases'] + [package['name'], package['full_name']]
# Create a list of tuples containing all possible combinations of
# names and versions, because all are valid.
combinations = [(n, v) for n in names for v in versions]
for name, version in combinations:
__salt__['pkg_resource.add_pkg'](ret, name, version)
# Grab packages from brew cask, if available.
# Brew Cask doesn't provide a JSON interface, must be parsed the old way.
try:
cask_cmd = 'cask list --versions'
out = _call_brew(cask_cmd)['stdout']
for line in out.splitlines():
try:
name_and_versions = line.split(' ')
pkg_name = name_and_versions[0]
# Get cask namespace
info_cmd = 'cask info {}'.format(pkg_name)
match = re.search(r'^From: .*/(.+?)/homebrew-(.+?)/.*$',
_call_brew(info_cmd)['stdout'], re.MULTILINE)
if match:
namespace = '/'.join((match.group(1).lower(),
match.group(2).lower()))
else:
namespace = 'homebrew/cask'
name = '/'.join((namespace, pkg_name))
installed_versions = name_and_versions[1:]
key_func = functools.cmp_to_key(salt.utils.versions.version_cmp)
newest_version = sorted(installed_versions, key=key_func).pop()
except ValueError:
continue
__salt__['pkg_resource.add_pkg'](ret, name, newest_version)
except CommandExecutionError:
pass
__salt__['pkg_resource.sort_pkglist'](ret)
__context__['pkg.list_pkgs'] = copy.deepcopy(ret)
if not versions_as_list:
__salt__['pkg_resource.stringify'](ret)
return ret
def version(*names, **kwargs):
'''
Returns a string representing the package version or an empty string if not
installed. If more than one package name is specified, a dict of
name/version pairs is returned.
CLI Example:
.. code-block:: bash
salt '*' pkg.version <package name>
salt '*' pkg.version <package1> <package2> <package3>
'''
return __salt__['pkg_resource.version'](*names, **kwargs)
def latest_version(*names, **kwargs):
'''
Return the latest version of the named package available for upgrade or
installation
Currently chooses stable versions, falling back to devel if that does not
exist.
CLI Example:
.. code-block:: bash
salt '*' pkg.latest_version <package name>
salt '*' pkg.latest_version <package1> <package2> <package3>
'''
refresh = salt.utils.data.is_true(kwargs.pop('refresh', True))
if refresh:
refresh_db()
def get_version(pkg_info):
# Perhaps this will need an option to pick devel by default
return pkg_info['versions']['stable'] or pkg_info['versions']['devel']
versions_dict = dict((key, get_version(val)) for key, val in six.iteritems(_info(*names)))
if len(names) == 1:
return next(six.itervalues(versions_dict))
else:
return versions_dict
# available_version is being deprecated
available_version = salt.utils.functools.alias_function(latest_version, 'available_version')
def remove(name=None, pkgs=None, **kwargs):
'''
Removes packages with ``brew uninstall``.
name
The name of the package to be deleted.
Multiple Package Options:
pkgs
A list of packages to delete. Must be passed as a python list. The
``name`` parameter will be ignored if this option is passed.
.. versionadded:: 0.16.0
Returns a dict containing the changes.
CLI Example:
.. code-block:: bash
salt '*' pkg.remove <package name>
salt '*' pkg.remove <package1>,<package2>,<package3>
salt '*' pkg.remove pkgs='["foo", "bar"]'
'''
try:
name, pkgs = _fix_cask_namespace(name, pkgs)
pkg_params = __salt__['pkg_resource.parse_targets'](
name, pkgs, **kwargs
)[0]
except MinionError as exc:
raise CommandExecutionError(exc)
old = list_pkgs()
targets = [x for x in pkg_params if x in old]
if not targets:
return {}
cmd = 'uninstall {0}'.format(' '.join(targets))
out = _call_brew(cmd)
if out['retcode'] != 0 and out['stderr']:
errors = [out['stderr']]
else:
errors = []
__context__.pop('pkg.list_pkgs', None)
new = list_pkgs()
ret = salt.utils.data.compare_dicts(old, new)
if errors:
raise CommandExecutionError(
'Problem encountered removing package(s)',
info={'errors': errors, 'changes': ret}
)
return ret
def refresh_db():
'''
Update the homebrew package repository.
CLI Example:
.. code-block:: bash
salt '*' pkg.refresh_db
'''
# Remove rtag file to keep multiple refreshes from happening in pkg states
salt.utils.pkg.clear_rtag(__opts__)
cmd = 'update'
if _call_brew(cmd)['retcode']:
log.error('Failed to update')
return False
return True
def _info(*pkgs):
'''
Get all info brew can provide about a list of packages.
Does not do any kind of processing, so the format depends entirely on
the output brew gives. This may change if a new version of the format is
requested.
On failure, returns an empty dict and logs failure.
On success, returns a dict mapping each item in pkgs to its corresponding
object in the output of 'brew info'.
Caveat: If one of the packages does not exist, no packages will be
included in the output.
'''
cmd = 'info --json=v1 {0}'.format(' '.join(pkgs))
brew_result = _call_brew(cmd)
if brew_result['retcode']:
log.error('Failed to get info about packages: %s',
' '.join(pkgs))
return {}
output = salt.utils.json.loads(brew_result['stdout'])
return dict(zip(pkgs, output))
def install(name=None, pkgs=None, taps=None, options=None, **kwargs):
'''
Install the passed package(s) with ``brew install``
name
The name of the formula to be installed. Note that this parameter is
ignored if "pkgs" is passed.
CLI Example:
.. code-block:: bash
salt '*' pkg.install <package name>
taps
Unofficial GitHub repos to use when updating and installing formulas.
CLI Example:
.. code-block:: bash
salt '*' pkg.install <package name> tap='<tap>'
salt '*' pkg.install zlib taps='homebrew/dupes'
salt '*' pkg.install php54 taps='["josegonzalez/php", "homebrew/dupes"]'
options
Options to pass to brew. Only applies to initial install. Due to how brew
works, modifying chosen options requires a full uninstall followed by a
fresh install. Note that if "pkgs" is used, all options will be passed
to all packages. Unrecognized options for a package will be silently
ignored by brew.
CLI Example:
.. code-block:: bash
salt '*' pkg.install <package name> tap='<tap>'
salt '*' pkg.install php54 taps='["josegonzalez/php", "homebrew/dupes"]' options='["--with-fpm"]'
Multiple Package Installation Options:
pkgs
A list of formulas to install. Must be passed as a python list.
CLI Example:
.. code-block:: bash
salt '*' pkg.install pkgs='["foo","bar"]'
Returns a dict containing the new package names and versions::
{'<package>': {'old': '<old-version>',
'new': '<new-version>'}}
CLI Example:
.. code-block:: bash
salt '*' pkg.install 'package package package'
'''
try:
name, pkgs = _fix_cask_namespace(name, pkgs)
pkg_params, pkg_type = __salt__['pkg_resource.parse_targets'](
name, pkgs, kwargs.get('sources', {})
)
except MinionError as exc:
raise CommandExecutionError(exc)
if pkg_params is None or len(pkg_params) == 0:
return {}
formulas = ' '.join(pkg_params)
old = list_pkgs()
# Ensure we've tapped the repo if necessary
if taps:
if not isinstance(taps, list):
# Feels like there is a better way to allow for tap being
# specified as both a string and a list
taps = [taps]
for tap in taps:
_tap(tap)
if options:
cmd = 'install {0} {1}'.format(formulas, ' '.join(options))
else:
cmd = 'install {0}'.format(formulas)
out = _call_brew(cmd)
if out['retcode'] != 0 and out['stderr']:
errors = [out['stderr']]
else:
errors = []
__context__.pop('pkg.list_pkgs', None)
new = list_pkgs()
ret = salt.utils.data.compare_dicts(old, new)
if errors:
raise CommandExecutionError(
'Problem encountered installing package(s)',
info={'errors': errors, 'changes': ret}
)
return ret
def list_upgrades(refresh=True, **kwargs): # pylint: disable=W0613
'''
Check whether or not an upgrade is available for all packages
CLI Example:
.. code-block:: bash
salt '*' pkg.list_upgrades
'''
if refresh:
refresh_db()
res = _call_brew('outdated --json=v1')
ret = {}
try:
data = salt.utils.json.loads(res['stdout'])
except ValueError as err:
msg = 'unable to interpret output from "brew outdated": {0}'.format(err)
log.error(msg)
raise CommandExecutionError(msg)
for pkg in data:
# current means latest available to brew
ret[pkg['name']] = pkg['current_version']
return ret
def upgrade_available(pkg):
'''
Check whether or not an upgrade is available for a given package
CLI Example:
.. code-block:: bash
salt '*' pkg.upgrade_available <package name>
'''
return pkg in list_upgrades()
def upgrade(refresh=True):
'''
Upgrade outdated, unpinned brews.
refresh
Fetch the newest version of Homebrew and all formulae from GitHub before installing.
Returns a dictionary containing the changes:
.. code-block:: python
{'<package>': {'old': '<old-version>',
'new': '<new-version>'}}
CLI Example:
.. code-block:: bash
salt '*' pkg.upgrade
'''
ret = {'changes': {},
'result': True,
'comment': '',
}
old = list_pkgs()
if salt.utils.data.is_true(refresh):
refresh_db()
result = _call_brew('upgrade', failhard=False)
__context__.pop('pkg.list_pkgs', None)
new = list_pkgs()
ret = salt.utils.data.compare_dicts(old, new)
if result['retcode'] != 0:
raise CommandExecutionError(
'Problem encountered upgrading packages',
info={'changes': ret, 'result': result}
)
return ret
def info_installed(*names):
'''
Return the information of the named package(s) installed on the system.
.. versionadded:: 2016.3.1
names
The names of the packages for which to return information.
CLI example:
.. code-block:: bash
salt '*' pkg.info_installed <package1>
salt '*' pkg.info_installed <package1> <package2> <package3> ...
'''
return _info(*names)
def _fix_cask_namespace(name=None, pkgs=None):
"""
Check if provided packages contains the old version of brew-cask namespace
and replace it by the new one.
This function also warns about the correct namespace for this packages
and it will stop working with the release of Sodium.
:param name: The name of the package to check
:param pkgs: A list of packages to check
:return: name and pkgs with the mocked namespace
"""
show_warning = False
if name and name.startswith('caskroom/cask/'):
show_warning = True
name = name.replace("caskroom/cask/", "homebrew/cask/")
if pkgs:
pkgs_ = []
for pkg in pkgs:
if isinstance(pkg, str) and pkg.startswith('caskroom/cask/'):
show_warning = True
pkg = pkg.replace("caskroom/cask/", "homebrew/cask/")
pkgs_.append(pkg)
else:
pkgs_.append(pkg)
continue
pkgs = pkgs_
if show_warning:
salt.utils.versions.warn_until(
'Sodium',
'The \'caskroom/cask/\' namespace for brew-cask packages '
'is deprecated. Use \'homebrew/cask/\' instead.'
)
return name, pkgs