1"""
2Support for MacPorts under macOS.
3
4This module has some caveats.
5
61. Updating the database of available ports is quite resource-intensive.
7However, `refresh=True` is the default for all operations that need an
8up-to-date copy of available ports.  Consider `refresh=False` when you are
9sure no db update is needed.
10
112. In some cases MacPorts doesn't always realize when another copy of itself
12is running and will gleefully tromp all over the available ports database.
13This makes MacPorts behave in undefined ways until a fresh complete
14copy is retrieved.
15
16Because of 1 and 2 it is possible to get the salt-minion into a state where
17`salt mac-machine pkg./something/` won't want to return.  Use
18
19`salt-run jobs.active`
20
21on the master to check for potentially long-running calls to `port`.
22
23Finally, ports database updates are always handled with `port selfupdate`
24as opposed to `port sync`.  This makes sense in the MacPorts user community
25but may confuse experienced Linux admins as Linux package managers
26don't upgrade the packaging software when doing a package database update.
27In other words `salt mac-machine pkg.refresh_db` is more like
28`apt-get update; apt-get upgrade dpkg apt-get` than simply `apt-get update`.
29
30"""
31
32import copy
33import logging
34import re
35
36import salt.utils.data
37import salt.utils.functools
38import salt.utils.mac_utils
39import salt.utils.path
40import salt.utils.pkg
41import salt.utils.platform
42import salt.utils.versions
43from salt.exceptions import CommandExecutionError
44
45log = logging.getLogger(__name__)
46
47LIST_ACTIVE_ONLY = True
48__virtualname__ = "pkg"
49
50
51def __virtual__():
52    """
53    Confine this module to Mac OS with MacPorts.
54    """
55    if not salt.utils.platform.is_darwin():
56        return False, "mac_ports only available on MacOS"
57
58    if not salt.utils.path.which("port"):
59        return False, 'mac_ports requires the "port" binary'
60
61    return __virtualname__
62
63
64def _list(query=""):
65    cmd = "port list {}".format(query)
66    out = salt.utils.mac_utils.execute_return_result(cmd)
67
68    ret = {}
69    for line in out.splitlines():
70        try:
71            name, version_num, category = re.split(r"\s+", line.lstrip())[0:3]
72            version_num = version_num[1:]
73        except ValueError:
74            continue
75        ret[name] = version_num
76
77    return ret
78
79
80def _list_pkgs_from_context(versions_as_list):
81    """
82    Use pkg list from __context__
83    """
84    if versions_as_list:
85        return __context__["pkg.list_pkgs"]
86    else:
87        ret = copy.deepcopy(__context__["pkg.list_pkgs"])
88        __salt__["pkg_resource.stringify"](ret)
89        return ret
90
91
92def list_pkgs(versions_as_list=False, **kwargs):
93    """
94    List the packages currently installed in a dict::
95
96        {'<package_name>': '<version>'}
97
98    CLI Example:
99
100    .. code-block:: bash
101
102        salt '*' pkg.list_pkgs
103    """
104    versions_as_list = salt.utils.data.is_true(versions_as_list)
105    # 'removed', 'purge_desired' not yet implemented or not applicable
106    if any(
107        [salt.utils.data.is_true(kwargs.get(x)) for x in ("removed", "purge_desired")]
108    ):
109        return {}
110
111    if "pkg.list_pkgs" in __context__ and kwargs.get("use_context", True):
112        return _list_pkgs_from_context(versions_as_list)
113
114    ret = {}
115    cmd = ["port", "installed"]
116    out = salt.utils.mac_utils.execute_return_result(cmd)
117    for line in out.splitlines():
118        try:
119            name, version_num, active = re.split(r"\s+", line.lstrip())[0:3]
120            version_num = version_num[1:]
121        except ValueError:
122            continue
123        if not LIST_ACTIVE_ONLY or active == "(active)":
124            __salt__["pkg_resource.add_pkg"](ret, name, version_num)
125
126    __salt__["pkg_resource.sort_pkglist"](ret)
127    __context__["pkg.list_pkgs"] = copy.deepcopy(ret)
128    if not versions_as_list:
129        __salt__["pkg_resource.stringify"](ret)
130    return ret
131
132
133def version(*names, **kwargs):
134    """
135    Returns a string representing the package version or an empty string if not
136    installed. If more than one package name is specified, a dict of
137    name/version pairs is returned.
138
139    CLI Example:
140
141    .. code-block:: bash
142
143        salt '*' pkg.version <package name>
144        salt '*' pkg.version <package1> <package2> <package3>
145    """
146    return __salt__["pkg_resource.version"](*names, **kwargs)
147
148
149def latest_version(*names, **kwargs):
150    """
151    Return the latest version of the named package available for upgrade or
152    installation
153
154    Options:
155
156    refresh
157        Update ports with ``port selfupdate``
158
159    CLI Example:
160
161    .. code-block:: bash
162
163        salt '*' pkg.latest_version <package name>
164        salt '*' pkg.latest_version <package1> <package2> <package3>
165    """
166
167    if salt.utils.data.is_true(kwargs.get("refresh", True)):
168        refresh_db()
169
170    available = _list(" ".join(names)) or {}
171    installed = __salt__["pkg.list_pkgs"]() or {}
172
173    ret = {}
174
175    for key, val in available.items():
176        if key not in installed or salt.utils.versions.compare(
177            ver1=installed[key], oper="<", ver2=val
178        ):
179            ret[key] = val
180        else:
181            ret[key] = "{} (installed)".format(version(key))
182
183    return ret
184
185
186# available_version is being deprecated
187available_version = salt.utils.functools.alias_function(
188    latest_version, "available_version"
189)
190
191
192def remove(name=None, pkgs=None, **kwargs):
193    """
194    Removes packages with ``port uninstall``.
195
196    name
197        The name of the package to be deleted.
198
199
200    Multiple Package Options:
201
202    pkgs
203        A list of packages to delete. Must be passed as a python list. The
204        ``name`` parameter will be ignored if this option is passed.
205
206    .. versionadded:: 0.16.0
207
208
209    Returns a dict containing the changes.
210
211    CLI Example:
212
213    .. code-block:: bash
214
215        salt '*' pkg.remove <package name>
216        salt '*' pkg.remove <package1>,<package2>,<package3>
217        salt '*' pkg.remove pkgs='["foo", "bar"]'
218    """
219    pkg_params = __salt__["pkg_resource.parse_targets"](name, pkgs, **kwargs)[0]
220    old = list_pkgs()
221    targets = [x for x in pkg_params if x in old]
222    if not targets:
223        return {}
224
225    cmd = ["port", "uninstall"]
226    cmd.extend(targets)
227
228    err_message = ""
229    try:
230        salt.utils.mac_utils.execute_return_success(cmd)
231    except CommandExecutionError as exc:
232        err_message = exc.strerror
233
234    __context__.pop("pkg.list_pkgs", None)
235    new = list_pkgs()
236    ret = salt.utils.data.compare_dicts(old, new)
237
238    if err_message:
239        raise CommandExecutionError(
240            "Problem encountered removing package(s)",
241            info={"errors": err_message, "changes": ret},
242        )
243
244    return ret
245
246
247def install(name=None, refresh=False, pkgs=None, **kwargs):
248    """
249    Install the passed package(s) with ``port install``
250
251    name
252        The name of the formula to be installed. Note that this parameter is
253        ignored if "pkgs" is passed.
254
255        CLI Example:
256
257        .. code-block:: bash
258
259            salt '*' pkg.install <package name>
260
261    version
262        Specify a version to pkg to install. Ignored if pkgs is specified.
263
264        CLI Example:
265
266        .. code-block:: bash
267
268            salt '*' pkg.install <package name>
269            salt '*' pkg.install git-core version='1.8.5.5'
270
271    variant
272        Specify a variant to pkg to install. Ignored if pkgs is specified.
273
274        CLI Example:
275
276        .. code-block:: bash
277
278            salt '*' pkg.install <package name>
279            salt '*' pkg.install git-core version='1.8.5.5' variant='+credential_osxkeychain+doc+pcre'
280
281    Multiple Package Installation Options:
282
283    pkgs
284        A list of formulas to install. Must be passed as a python list.
285
286        CLI Example:
287
288        .. code-block:: bash
289
290            salt '*' pkg.install pkgs='["foo","bar"]'
291            salt '*' pkg.install pkgs='["foo@1.2","bar"]'
292            salt '*' pkg.install pkgs='["foo@1.2+ssl","bar@2.3"]'
293
294
295    Returns a dict containing the new package names and versions::
296
297        {'<package>': {'old': '<old-version>',
298                       'new': '<new-version>'}}
299
300    CLI Example:
301
302    .. code-block:: bash
303
304        salt '*' pkg.install 'package package package'
305    """
306    pkg_params, pkg_type = __salt__["pkg_resource.parse_targets"](name, pkgs, {})
307
308    if salt.utils.data.is_true(refresh):
309        refresh_db()
310
311    # Handle version kwarg for a single package target
312    if pkgs is None:
313        version_num = kwargs.get("version")
314        variant_spec = kwargs.get("variant")
315        spec = {}
316
317        if version_num:
318            spec["version"] = version_num
319
320        if variant_spec:
321            spec["variant"] = variant_spec
322
323        pkg_params = {name: spec}
324
325    if not pkg_params:
326        return {}
327
328    formulas_array = []
329    for pname, pparams in pkg_params.items():
330        formulas_array.append(pname)
331
332        if pparams:
333            if "version" in pparams:
334                formulas_array.append("@" + pparams["version"])
335
336            if "variant" in pparams:
337                formulas_array.append(pparams["variant"])
338
339    old = list_pkgs()
340    cmd = ["port", "install"]
341    cmd.extend(formulas_array)
342
343    err_message = ""
344    try:
345        salt.utils.mac_utils.execute_return_success(cmd)
346    except CommandExecutionError as exc:
347        err_message = exc.strerror
348
349    __context__.pop("pkg.list_pkgs", None)
350    new = list_pkgs()
351    ret = salt.utils.data.compare_dicts(old, new)
352
353    if err_message:
354        raise CommandExecutionError(
355            "Problem encountered installing package(s)",
356            info={"errors": err_message, "changes": ret},
357        )
358
359    return ret
360
361
362def list_upgrades(refresh=True, **kwargs):  # pylint: disable=W0613
363    """
364    Check whether or not an upgrade is available for all packages
365
366    Options:
367
368    refresh
369        Update ports with ``port selfupdate``
370
371    CLI Example:
372
373    .. code-block:: bash
374
375        salt '*' pkg.list_upgrades
376    """
377
378    if refresh:
379        refresh_db()
380    return _list("outdated")
381
382
383def upgrade_available(pkg, refresh=True, **kwargs):
384    """
385    Check whether or not an upgrade is available for a given package
386
387    CLI Example:
388
389    .. code-block:: bash
390
391        salt '*' pkg.upgrade_available <package name>
392    """
393    return pkg in list_upgrades(refresh=refresh)
394
395
396def refresh_db(**kwargs):
397    """
398    Update ports with ``port selfupdate``
399
400    CLI Example:
401
402    .. code-block:: bash
403
404        salt mac pkg.refresh_db
405    """
406    # Remove rtag file to keep multiple refreshes from happening in pkg states
407    salt.utils.pkg.clear_rtag(__opts__)
408    cmd = ["port", "selfupdate"]
409    return salt.utils.mac_utils.execute_return_success(cmd)
410
411
412def upgrade(refresh=True, **kwargs):  # pylint: disable=W0613
413    """
414    Run a full upgrade using MacPorts 'port upgrade outdated'
415
416    Options:
417
418    refresh
419        Update ports with ``port selfupdate``
420
421    Returns a dictionary containing the changes:
422
423    .. code-block:: python
424
425        {'<package>':  {'old': '<old-version>',
426                        'new': '<new-version>'}}
427
428    CLI Example:
429
430    .. code-block:: bash
431
432        salt '*' pkg.upgrade
433    """
434    if refresh:
435        refresh_db()
436
437    old = list_pkgs()
438    cmd = ["port", "upgrade", "outdated"]
439    result = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False)
440    __context__.pop("pkg.list_pkgs", None)
441    new = list_pkgs()
442    ret = salt.utils.data.compare_dicts(old, new)
443
444    if result["retcode"] != 0:
445        raise CommandExecutionError(
446            "Problem encountered upgrading packages",
447            info={"changes": ret, "result": result},
448        )
449
450    return ret
451