1"""
2Remote package support using ``pkg_add(1)``
3
4.. important::
5    If you feel that Salt should be using this module to manage packages on a
6    minion, and it is using a different module (or gives an error similar to
7    *'pkg.install' is not available*), see :ref:`here
8    <module-provider-override>`.
9
10.. warning::
11
12    This module has been completely rewritten. Up to and including version
13    0.17.0, it supported ``pkg_add(1)``, but checked for the existence of a
14    pkgng local database and, if found,  would provide some of pkgng's
15    functionality. The rewrite of this module has removed all pkgng support,
16    and moved it to the :mod:`pkgng <salt.modules.pkgng>` execution module. For
17    versions <= 0.17.0, the documentation here should not be considered
18    accurate. If your Minion is running one of these versions, then the
19    documentation for this module can be viewed using the :mod:`sys.doc
20    <salt.modules.sys.doc>` function:
21
22    .. code-block:: bash
23
24        salt bsdminion sys.doc pkg
25
26
27This module acts as the default package provider for FreeBSD 9 and older. If
28you need to use pkgng on a FreeBSD 9 system, you will need to override the
29``pkg`` provider by setting the :conf_minion:`providers` parameter in your
30Minion config file, in order to use pkgng.
31
32.. code-block:: yaml
33
34    providers:
35      pkg: pkgng
36
37More information on pkgng support can be found in the documentation for the
38:mod:`pkgng <salt.modules.pkgng>` module.
39
40This module will respect the ``PACKAGEROOT`` and ``PACKAGESITE`` environment
41variables, if set, but these values can also be overridden in several ways:
42
431. :strong:`Salt configuration parameters.` The configuration parameters
44   ``freebsdpkg.PACKAGEROOT`` and ``freebsdpkg.PACKAGESITE`` are recognized.
45   These config parameters are looked up using :mod:`config.get
46   <salt.modules.config.get>` and can thus be specified in the Master config
47   file, Grains, Pillar, or in the Minion config file. Example:
48
49   .. code-block:: yaml
50
51        freebsdpkg.PACKAGEROOT: ftp://ftp.freebsd.org/
52        freebsdpkg.PACKAGESITE: ftp://ftp.freebsd.org/pub/FreeBSD/ports/ia64/packages-9-stable/Latest/
53
542. :strong:`CLI arguments.` Both the ``packageroot`` (used interchangeably with
55   ``fromrepo`` for API compatibility) and ``packagesite`` CLI arguments are
56   recognized, and override their config counterparts from section 1 above.
57
58   .. code-block:: bash
59
60        salt -G 'os:FreeBSD' pkg.install zsh fromrepo=ftp://ftp2.freebsd.org/
61        salt -G 'os:FreeBSD' pkg.install zsh packageroot=ftp://ftp2.freebsd.org/
62        salt -G 'os:FreeBSD' pkg.install zsh packagesite=ftp://ftp2.freebsd.org/pub/FreeBSD/ports/ia64/packages-9-stable/Latest/
63
64    .. note::
65
66        These arguments can also be passed through in states:
67
68        .. code-block:: yaml
69
70            zsh:
71              pkg.installed:
72                - fromrepo: ftp://ftp2.freebsd.org/
73"""
74
75import copy
76import logging
77import re
78
79import salt.utils.data
80import salt.utils.functools
81import salt.utils.pkg
82from salt.exceptions import CommandExecutionError, MinionError
83
84log = logging.getLogger(__name__)
85
86# Define the module's virtual name
87__virtualname__ = "pkg"
88
89
90def __virtual__():
91    """
92    Load as 'pkg' on FreeBSD versions less than 10.
93    Don't load on FreeBSD 9 when the config option
94    ``providers:pkg`` is set to 'pkgng'.
95    """
96    if __grains__["os"] == "FreeBSD" and float(__grains__["osrelease"]) < 10:
97        providers = {}
98        if "providers" in __opts__:
99            providers = __opts__["providers"]
100        if providers and "pkg" in providers and providers["pkg"] == "pkgng":
101            log.debug(
102                "Configuration option 'providers:pkg' is set to "
103                "'pkgng', won't load old provider 'freebsdpkg'."
104            )
105            return (
106                False,
107                "The freebsdpkg execution module cannot be loaded: the configuration"
108                " option 'providers:pkg' is set to 'pkgng'",
109            )
110        return __virtualname__
111    return (
112        False,
113        "The freebsdpkg execution module cannot be loaded: either the os is not FreeBSD"
114        " or the version of FreeBSD is >= 10.",
115    )
116
117
118def _get_repo_options(fromrepo=None, packagesite=None):
119    """
120    Return a list of tuples to seed the "env" list, which is used to set
121    environment variables for any pkg_add commands that are spawned.
122
123    If ``fromrepo`` or ``packagesite`` are None, then their corresponding
124    config parameter will be looked up with config.get.
125
126    If both ``fromrepo`` and ``packagesite`` are None, and neither
127    freebsdpkg.PACKAGEROOT nor freebsdpkg.PACKAGESITE are specified, then an
128    empty list is returned, and it is assumed that the system defaults (or
129    environment variables) will be used.
130    """
131    root = (
132        fromrepo
133        if fromrepo is not None
134        else __salt__["config.get"]("freebsdpkg.PACKAGEROOT", None)
135    )
136    site = (
137        packagesite
138        if packagesite is not None
139        else __salt__["config.get"]("freebsdpkg.PACKAGESITE", None)
140    )
141    ret = {}
142    if root is not None:
143        ret["PACKAGEROOT"] = root
144    if site is not None:
145        ret["PACKAGESITE"] = site
146    return ret
147
148
149def _match(names):
150    """
151    Since pkg_delete requires the full "pkgname-version" string, this function
152    will attempt to match the package name with its version. Returns a list of
153    partial matches and package names that match the "pkgname-version" string
154    required by pkg_delete, and a list of errors encountered.
155    """
156    pkgs = list_pkgs(versions_as_list=True)
157    errors = []
158
159    # Look for full matches
160    full_pkg_strings = []
161    out = __salt__["cmd.run_stdout"](
162        ["pkg_info"], output_loglevel="trace", python_shell=False
163    )
164    for line in out.splitlines():
165        try:
166            full_pkg_strings.append(line.split()[0])
167        except IndexError:
168            continue
169    full_matches = [x for x in names if x in full_pkg_strings]
170
171    # Look for pkgname-only matches
172    matches = []
173    ambiguous = []
174    for name in set(names) - set(full_matches):
175        cver = pkgs.get(name)
176        if cver is not None:
177            if len(cver) == 1:
178                matches.append("{}-{}".format(name, cver[0]))
179            else:
180                ambiguous.append(name)
181                errors.append(
182                    "Ambiguous package '{}'. Full name/version required. "
183                    "Possible matches: {}".format(
184                        name, ", ".join(["{}-{}".format(name, x) for x in cver])
185                    )
186                )
187
188    # Find packages that did not match anything
189    not_matched = set(names) - set(matches) - set(full_matches) - set(ambiguous)
190    for name in not_matched:
191        errors.append("Package '{}' not found".format(name))
192
193    return matches + full_matches, errors
194
195
196def latest_version(*names, **kwargs):
197    """
198    ``pkg_add(1)`` is not capable of querying for remote packages, so this
199    function will always return results as if there is no package available for
200    install or upgrade.
201
202    CLI Example:
203
204    .. code-block:: bash
205
206        salt '*' pkg.latest_version <package name>
207        salt '*' pkg.latest_version <package1> <package2> <package3> ...
208    """
209    return "" if len(names) == 1 else {x: "" for x in names}
210
211
212# available_version is being deprecated
213available_version = salt.utils.functools.alias_function(
214    latest_version, "available_version"
215)
216
217
218def version(*names, **kwargs):
219    """
220    Returns a string representing the package version or an empty string if not
221    installed. If more than one package name is specified, a dict of
222    name/version pairs is returned.
223
224    with_origin : False
225        Return a nested dictionary containing both the origin name and version
226        for each specified package.
227
228        .. versionadded:: 2014.1.0
229
230    CLI Example:
231
232    .. code-block:: bash
233
234        salt '*' pkg.version <package name>
235        salt '*' pkg.version <package1> <package2> <package3> ...
236    """
237    with_origin = kwargs.pop("with_origin", False)
238    ret = __salt__["pkg_resource.version"](*names, **kwargs)
239    if not salt.utils.data.is_true(with_origin):
240        return ret
241    # Put the return value back into a dict since we're adding a subdict
242    if len(names) == 1:
243        ret = {names[0]: ret}
244    origins = __context__.get("pkg.origin", {})
245    return {x: {"origin": origins.get(x, ""), "version": y} for x, y in ret.items()}
246
247
248def refresh_db(**kwargs):
249    """
250    ``pkg_add(1)`` does not use a local database of available packages, so this
251    function simply returns ``True``. it exists merely for API compatibility.
252
253    CLI Example:
254
255    .. code-block:: bash
256
257        salt '*' pkg.refresh_db
258    """
259    # Remove rtag file to keep multiple refreshes from happening in pkg states
260    salt.utils.pkg.clear_rtag(__opts__)
261    return True
262
263
264def _list_pkgs_from_context(versions_as_list, with_origin):
265    """
266    Use pkg list from __context__
267    """
268    ret = copy.deepcopy(__context__["pkg.list_pkgs"])
269    if not versions_as_list:
270        __salt__["pkg_resource.stringify"](ret)
271    if salt.utils.data.is_true(with_origin):
272        origins = __context__.get("pkg.origin", {})
273        return {x: {"origin": origins.get(x, ""), "version": y} for x, y in ret.items()}
274    return ret
275
276
277def list_pkgs(versions_as_list=False, with_origin=False, **kwargs):
278    """
279    List the packages currently installed as a dict::
280
281        {'<package_name>': '<version>'}
282
283    with_origin : False
284        Return a nested dictionary containing both the origin name and version
285        for each installed package.
286
287        .. versionadded:: 2014.1.0
288
289    CLI Example:
290
291    .. code-block:: bash
292
293        salt '*' pkg.list_pkgs
294    """
295    versions_as_list = salt.utils.data.is_true(versions_as_list)
296    # not yet implemented or not applicable
297    if any(
298        [salt.utils.data.is_true(kwargs.get(x)) for x in ("removed", "purge_desired")]
299    ):
300        return {}
301
302    if "pkg.list_pkgs" in __context__ and kwargs.get("use_context", True):
303        return _list_pkgs_from_context(versions_as_list, with_origin)
304
305    ret = {}
306    origins = {}
307    out = __salt__["cmd.run_stdout"](
308        ["pkg_info", "-ao"], output_loglevel="trace", python_shell=False
309    )
310    pkgs_re = re.compile(r"Information for ([^:]+):\s*Origin:\n([^\n]+)")
311    for pkg, origin in pkgs_re.findall(out):
312        if not pkg:
313            continue
314        try:
315            pkgname, pkgver = pkg.rsplit("-", 1)
316        except ValueError:
317            continue
318        __salt__["pkg_resource.add_pkg"](ret, pkgname, pkgver)
319        origins[pkgname] = origin
320
321    __salt__["pkg_resource.sort_pkglist"](ret)
322    __context__["pkg.list_pkgs"] = copy.deepcopy(ret)
323    __context__["pkg.origin"] = origins
324    if not versions_as_list:
325        __salt__["pkg_resource.stringify"](ret)
326    if salt.utils.data.is_true(with_origin):
327        return {x: {"origin": origins.get(x, ""), "version": y} for x, y in ret.items()}
328    return ret
329
330
331def install(name=None, refresh=False, fromrepo=None, pkgs=None, sources=None, **kwargs):
332    """
333    Install package(s) using ``pkg_add(1)``
334
335    name
336        The name of the package to be installed.
337
338    refresh
339        Whether or not to refresh the package database before installing.
340
341    fromrepo or packageroot
342        Specify a package repository from which to install. Overrides the
343        system default, as well as the PACKAGEROOT environment variable.
344
345    packagesite
346        Specify the exact directory from which to install the remote package.
347        Overrides the PACKAGESITE environment variable, if present.
348
349
350    Multiple Package Installation Options:
351
352    pkgs
353        A list of packages to install from a software repository. Must be
354        passed as a python list.
355
356        CLI Example:
357
358        .. code-block:: bash
359
360            salt '*' pkg.install pkgs='["foo", "bar"]'
361
362    sources
363        A list of packages to install. Must be passed as a list of dicts,
364        with the keys being package names, and the values being the source URI
365        or local path to the package.
366
367        CLI Example:
368
369        .. code-block:: bash
370
371            salt '*' pkg.install sources='[{"foo": "salt://foo.deb"}, {"bar": "salt://bar.deb"}]'
372
373    Return a dict containing the new package names and versions::
374
375        {'<package>': {'old': '<old-version>',
376                       'new': '<new-version>'}}
377
378    CLI Example:
379
380    .. code-block:: bash
381
382        salt '*' pkg.install <package name>
383    """
384    try:
385        pkg_params, pkg_type = __salt__["pkg_resource.parse_targets"](
386            name, pkgs, sources, **kwargs
387        )
388    except MinionError as exc:
389        raise CommandExecutionError(exc)
390
391    if not pkg_params:
392        return {}
393
394    packageroot = kwargs.get("packageroot")
395    if not fromrepo and packageroot:
396        fromrepo = packageroot
397
398    env = _get_repo_options(fromrepo, kwargs.get("packagesite"))
399    args = []
400
401    if pkg_type == "repository":
402        args.append("-r")  # use remote repo
403
404    args.extend(pkg_params)
405
406    old = list_pkgs()
407    out = __salt__["cmd.run_all"](
408        ["pkg_add"] + args, env=env, output_loglevel="trace", python_shell=False
409    )
410    if out["retcode"] != 0 and out["stderr"]:
411        errors = [out["stderr"]]
412    else:
413        errors = []
414
415    __context__.pop("pkg.list_pkgs", None)
416    new = list_pkgs()
417    _rehash()
418    ret = salt.utils.data.compare_dicts(old, new)
419
420    if errors:
421        raise CommandExecutionError(
422            "Problem encountered installing package(s)",
423            info={"errors": errors, "changes": ret},
424        )
425
426    return ret
427
428
429def remove(name=None, pkgs=None, **kwargs):
430    """
431    Remove packages using ``pkg_delete(1)``
432
433    name
434        The name of the package to be deleted.
435
436
437    Multiple Package Options:
438
439    pkgs
440        A list of packages to delete. Must be passed as a python list. The
441        ``name`` parameter will be ignored if this option is passed.
442
443    .. versionadded:: 0.16.0
444
445
446    Returns a dict containing the changes.
447
448    CLI Example:
449
450    .. code-block:: bash
451
452        salt '*' pkg.remove <package name>
453        salt '*' pkg.remove <package1>,<package2>,<package3>
454        salt '*' pkg.remove pkgs='["foo", "bar"]'
455    """
456    try:
457        pkg_params = __salt__["pkg_resource.parse_targets"](name, pkgs)[0]
458    except MinionError as exc:
459        raise CommandExecutionError(exc)
460
461    old = list_pkgs()
462    targets, errors = _match([x for x in pkg_params])
463    for error in errors:
464        log.error(error)
465    if not targets:
466        return {}
467
468    out = __salt__["cmd.run_all"](
469        ["pkg_delete"] + targets, output_loglevel="trace", python_shell=False
470    )
471    if out["retcode"] != 0 and out["stderr"]:
472        errors = [out["stderr"]]
473    else:
474        errors = []
475
476    __context__.pop("pkg.list_pkgs", None)
477    new = list_pkgs()
478    ret = salt.utils.data.compare_dicts(old, new)
479
480    if errors:
481        raise CommandExecutionError(
482            "Problem encountered removing package(s)",
483            info={"errors": errors, "changes": ret},
484        )
485
486    return ret
487
488
489# Support pkg.delete to remove packages to more closely match pkg_delete
490delete = salt.utils.functools.alias_function(remove, "delete")
491# No equivalent to purge packages, use remove instead
492purge = salt.utils.functools.alias_function(remove, "purge")
493
494
495def _rehash():
496    """
497    Recomputes internal hash table for the PATH variable. Use whenever a new
498    command is created during the current session.
499    """
500    shell = __salt__["environ.get"]("SHELL")
501    if shell.split("/")[-1] in ("csh", "tcsh"):
502        __salt__["cmd.shell"]("rehash", output_loglevel="trace")
503
504
505def file_list(*packages, **kwargs):
506    """
507    List the files that belong to a package. Not specifying any packages will
508    return a list of _every_ file on the system's package database (not
509    generally recommended).
510
511    CLI Examples:
512
513    .. code-block:: bash
514
515        salt '*' pkg.file_list httpd
516        salt '*' pkg.file_list httpd postfix
517        salt '*' pkg.file_list
518    """
519    ret = file_dict(*packages)
520    files = []
521    for pkg_files in ret["files"].values():
522        files.extend(pkg_files)
523    ret["files"] = files
524    return ret
525
526
527def file_dict(*packages, **kwargs):
528    """
529    List the files that belong to a package, grouped by package. Not
530    specifying any packages will return a list of _every_ file on the
531    system's package database (not generally recommended).
532
533    CLI Examples:
534
535    .. code-block:: bash
536
537        salt '*' pkg.file_list httpd
538        salt '*' pkg.file_list httpd postfix
539        salt '*' pkg.file_list
540    """
541    errors = []
542    files = {}
543
544    if packages:
545        match_pattern = "'{0}-[0-9]*'"
546        cmd = ["pkg_info", "-QL"] + [match_pattern.format(p) for p in packages]
547    else:
548        cmd = ["pkg_info", "-QLa"]
549
550    ret = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False)
551
552    for line in ret["stderr"].splitlines():
553        errors.append(line)
554
555    pkg = None
556    for line in ret["stdout"].splitlines():
557        if pkg is not None and line.startswith("/"):
558            files[pkg].append(line)
559        elif ":/" in line:
560            pkg, fn = line.split(":", 1)
561            pkg, ver = pkg.rsplit("-", 1)
562            files[pkg] = [fn]
563        else:
564            continue  # unexpected string
565
566    return {"errors": errors, "files": files}
567