1#!/usr/bin/python
2# (c) 2017, Ansible Project
3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
4
5# most of it copied from AWX's scan_packages module
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10
11DOCUMENTATION = '''
12module: package_facts
13short_description: Package information as facts
14description:
15  - Return information about installed packages as facts.
16options:
17  manager:
18    description:
19      - The package manager used by the system so we can query the package information.
20      - Since 2.8 this is a list and can support multiple package managers per system.
21      - The 'portage' and 'pkg' options were added in version 2.8.
22    default: ['auto']
23    choices: ['auto', 'rpm', 'apt', 'portage', 'pkg', 'pacman']
24    required: False
25    type: list
26  strategy:
27    description:
28      - This option controls how the module queries the package managers on the system.
29        C(first) means it will return only information for the first supported package manager available.
30        C(all) will return information for all supported and available package managers on the system.
31    choices: ['first', 'all']
32    default: 'first'
33    version_added: "2.8"
34version_added: "2.5"
35requirements:
36    - For 'portage' support it requires the C(qlist) utility, which is part of 'app-portage/portage-utils'.
37    - For Debian-based systems C(python-apt) package must be installed on targeted hosts.
38author:
39  - Matthew Jones (@matburt)
40  - Brian Coca (@bcoca)
41  - Adam Miller (@maxamillion)
42notes:
43  - Supports C(check_mode).
44'''
45
46EXAMPLES = '''
47- name: Gather the package facts
48  ansible.builtin.package_facts:
49    manager: auto
50
51- name: Print the package facts
52  ansible.builtin.debug:
53    var: ansible_facts.packages
54
55- name: Check whether a package called foobar is installed
56  ansible.builtin.debug:
57    msg: "{{ ansible_facts.packages['foobar'] | length }} versions of foobar are installed!"
58  when: "'foobar' in ansible_facts.packages"
59
60'''
61
62RETURN = '''
63ansible_facts:
64  description: Facts to add to ansible_facts.
65  returned: always
66  type: complex
67  contains:
68    packages:
69      description:
70        - Maps the package name to a non-empty list of dicts with package information.
71        - Every dict in the list corresponds to one installed version of the package.
72        - The fields described below are present for all package managers. Depending on the
73          package manager, there might be more fields for a package.
74      returned: when operating system level package manager is specified or auto detected manager
75      type: dict
76      contains:
77        name:
78          description: The package's name.
79          returned: always
80          type: str
81        version:
82          description: The package's version.
83          returned: always
84          type: str
85        source:
86          description: Where information on the package came from.
87          returned: always
88          type: str
89      sample: |-
90        {
91          "packages": {
92            "kernel": [
93              {
94                "name": "kernel",
95                "source": "rpm",
96                "version": "3.10.0",
97                ...
98              },
99              {
100                "name": "kernel",
101                "source": "rpm",
102                "version": "3.10.0",
103                ...
104              },
105              ...
106            ],
107            "kernel-tools": [
108              {
109                "name": "kernel-tools",
110                "source": "rpm",
111                "version": "3.10.0",
112                ...
113              }
114            ],
115            ...
116          }
117        }
118        # Sample rpm
119        {
120          "packages": {
121            "kernel": [
122              {
123                "arch": "x86_64",
124                "epoch": null,
125                "name": "kernel",
126                "release": "514.26.2.el7",
127                "source": "rpm",
128                "version": "3.10.0"
129              },
130              {
131                "arch": "x86_64",
132                "epoch": null,
133                "name": "kernel",
134                "release": "514.16.1.el7",
135                "source": "rpm",
136                "version": "3.10.0"
137              },
138              {
139                "arch": "x86_64",
140                "epoch": null,
141                "name": "kernel",
142                "release": "514.10.2.el7",
143                "source": "rpm",
144                "version": "3.10.0"
145              },
146              {
147                "arch": "x86_64",
148                "epoch": null,
149                "name": "kernel",
150                "release": "514.21.1.el7",
151                "source": "rpm",
152                "version": "3.10.0"
153              },
154              {
155                "arch": "x86_64",
156                "epoch": null,
157                "name": "kernel",
158                "release": "693.2.2.el7",
159                "source": "rpm",
160                "version": "3.10.0"
161              }
162            ],
163            "kernel-tools": [
164              {
165                "arch": "x86_64",
166                "epoch": null,
167                "name": "kernel-tools",
168                "release": "693.2.2.el7",
169                "source": "rpm",
170                "version": "3.10.0"
171              }
172            ],
173            "kernel-tools-libs": [
174              {
175                "arch": "x86_64",
176                "epoch": null,
177                "name": "kernel-tools-libs",
178                "release": "693.2.2.el7",
179                "source": "rpm",
180                "version": "3.10.0"
181              }
182            ],
183          }
184        }
185        # Sample deb
186        {
187          "packages": {
188            "libbz2-1.0": [
189              {
190                "version": "1.0.6-5",
191                "source": "apt",
192                "arch": "amd64",
193                "name": "libbz2-1.0"
194              }
195            ],
196            "patch": [
197              {
198                "version": "2.7.1-4ubuntu1",
199                "source": "apt",
200                "arch": "amd64",
201                "name": "patch"
202              }
203            ],
204          }
205        }
206'''
207
208import re
209
210from ansible.module_utils._text import to_native, to_text
211from ansible.module_utils.basic import AnsibleModule, missing_required_lib
212from ansible.module_utils.common.process import get_bin_path
213from ansible.module_utils.facts.packages import LibMgr, CLIMgr, get_all_pkg_managers
214
215
216class RPM(LibMgr):
217
218    LIB = 'rpm'
219
220    def list_installed(self):
221        return self._lib.TransactionSet().dbMatch()
222
223    def get_package_details(self, package):
224        return dict(name=package[self._lib.RPMTAG_NAME],
225                    version=package[self._lib.RPMTAG_VERSION],
226                    release=package[self._lib.RPMTAG_RELEASE],
227                    epoch=package[self._lib.RPMTAG_EPOCH],
228                    arch=package[self._lib.RPMTAG_ARCH],)
229
230    def is_available(self):
231        ''' we expect the python bindings installed, but this gives warning if they are missing and we have rpm cli'''
232        we_have_lib = super(RPM, self).is_available()
233
234        try:
235            get_bin_path('rpm')
236            if not we_have_lib:
237                module.warn('Found "rpm" but %s' % (missing_required_lib('rpm')))
238        except ValueError:
239            pass
240
241        return we_have_lib
242
243
244class APT(LibMgr):
245
246    LIB = 'apt'
247
248    def __init__(self):
249        self._cache = None
250        super(APT, self).__init__()
251
252    @property
253    def pkg_cache(self):
254        if self._cache is not None:
255            return self._cache
256
257        self._cache = self._lib.Cache()
258        return self._cache
259
260    def is_available(self):
261        ''' we expect the python bindings installed, but if there is apt/apt-get give warning about missing bindings'''
262        we_have_lib = super(APT, self).is_available()
263        if not we_have_lib:
264            for exe in ('apt', 'apt-get', 'aptitude'):
265                try:
266                    get_bin_path(exe)
267                except ValueError:
268                    continue
269                else:
270                    module.warn('Found "%s" but %s' % (exe, missing_required_lib('apt')))
271                    break
272        return we_have_lib
273
274    def list_installed(self):
275        # Store the cache to avoid running pkg_cache() for each item in the comprehension, which is very slow
276        cache = self.pkg_cache
277        return [pk for pk in cache.keys() if cache[pk].is_installed]
278
279    def get_package_details(self, package):
280        ac_pkg = self.pkg_cache[package].installed
281        return dict(name=package, version=ac_pkg.version, arch=ac_pkg.architecture, category=ac_pkg.section, origin=ac_pkg.origins[0].origin)
282
283
284class PACMAN(CLIMgr):
285
286    CLI = 'pacman'
287
288    def list_installed(self):
289        rc, out, err = module.run_command([self._cli, '-Qi'], environ_update=dict(LC_ALL='C'))
290        if rc != 0 or err:
291            raise Exception("Unable to list packages rc=%s : %s" % (rc, err))
292        return out.split("\n\n")[:-1]
293
294    def get_package_details(self, package):
295        # parse values of details that might extend over several lines
296        raw_pkg_details = {}
297        last_detail = None
298        for line in package.splitlines():
299            m = re.match(r"([\w ]*[\w]) +: (.*)", line)
300            if m:
301                last_detail = m.group(1)
302                raw_pkg_details[last_detail] = m.group(2)
303            else:
304                # append value to previous detail
305                raw_pkg_details[last_detail] = raw_pkg_details[last_detail] + "  " + line.lstrip()
306
307        provides = None
308        if raw_pkg_details['Provides'] != 'None':
309            provides = [
310                p.split('=')[0]
311                for p in raw_pkg_details['Provides'].split('  ')
312            ]
313
314        return {
315            'name': raw_pkg_details['Name'],
316            'version': raw_pkg_details['Version'],
317            'arch': raw_pkg_details['Architecture'],
318            'provides': provides,
319        }
320
321
322class PKG(CLIMgr):
323
324    CLI = 'pkg'
325    atoms = ['name', 'version', 'origin', 'installed', 'automatic', 'arch', 'category', 'prefix', 'vital']
326
327    def list_installed(self):
328        rc, out, err = module.run_command([self._cli, 'query', "%%%s" % '\t%'.join(['n', 'v', 'R', 't', 'a', 'q', 'o', 'p', 'V'])])
329        if rc != 0 or err:
330            raise Exception("Unable to list packages rc=%s : %s" % (rc, err))
331        return out.splitlines()
332
333    def get_package_details(self, package):
334
335        pkg = dict(zip(self.atoms, package.split('\t')))
336
337        if 'arch' in pkg:
338            try:
339                pkg['arch'] = pkg['arch'].split(':')[2]
340            except IndexError:
341                pass
342
343        if 'automatic' in pkg:
344            pkg['automatic'] = bool(int(pkg['automatic']))
345
346        if 'category' in pkg:
347            pkg['category'] = pkg['category'].split('/', 1)[0]
348
349        if 'version' in pkg:
350            if ',' in pkg['version']:
351                pkg['version'], pkg['port_epoch'] = pkg['version'].split(',', 1)
352            else:
353                pkg['port_epoch'] = 0
354
355            if '_' in pkg['version']:
356                pkg['version'], pkg['revision'] = pkg['version'].split('_', 1)
357            else:
358                pkg['revision'] = '0'
359
360        if 'vital' in pkg:
361            pkg['vital'] = bool(int(pkg['vital']))
362
363        return pkg
364
365
366class PORTAGE(CLIMgr):
367
368    CLI = 'qlist'
369    atoms = ['category', 'name', 'version', 'ebuild_revision', 'slots', 'prefixes', 'sufixes']
370
371    def list_installed(self):
372        rc, out, err = module.run_command(' '.join([self._cli, '-Iv', '|', 'xargs', '-n', '1024', 'qatom']), use_unsafe_shell=True)
373        if rc != 0:
374            raise RuntimeError("Unable to list packages rc=%s : %s" % (rc, to_native(err)))
375        return out.splitlines()
376
377    def get_package_details(self, package):
378        return dict(zip(self.atoms, package.split()))
379
380
381def main():
382
383    # get supported pkg managers
384    PKG_MANAGERS = get_all_pkg_managers()
385    PKG_MANAGER_NAMES = [x.lower() for x in PKG_MANAGERS.keys()]
386
387    # start work
388    global module
389    module = AnsibleModule(argument_spec=dict(manager={'type': 'list', 'default': ['auto']},
390                                              strategy={'choices': ['first', 'all'], 'default': 'first'}),
391                           supports_check_mode=True)
392    packages = {}
393    results = {'ansible_facts': {}}
394    managers = [x.lower() for x in module.params['manager']]
395    strategy = module.params['strategy']
396
397    if 'auto' in managers:
398        # keep order from user, we do dedupe below
399        managers.extend(PKG_MANAGER_NAMES)
400        managers.remove('auto')
401
402    unsupported = set(managers).difference(PKG_MANAGER_NAMES)
403    if unsupported:
404        if 'auto' in module.params['manager']:
405            msg = 'Could not auto detect a usable package manager, check warnings for details.'
406        else:
407            msg = 'Unsupported package managers requested: %s' % (', '.join(unsupported))
408        module.fail_json(msg=msg)
409
410    found = 0
411    seen = set()
412    for pkgmgr in managers:
413
414        if found and strategy == 'first':
415            break
416
417        # dedupe as per above
418        if pkgmgr in seen:
419            continue
420        seen.add(pkgmgr)
421        try:
422            try:
423                # manager throws exception on init (calls self.test) if not usable.
424                manager = PKG_MANAGERS[pkgmgr]()
425                if manager.is_available():
426                    found += 1
427                    packages.update(manager.get_packages())
428
429            except Exception as e:
430                if pkgmgr in module.params['manager']:
431                    module.warn('Requested package manager %s was not usable by this module: %s' % (pkgmgr, to_text(e)))
432                continue
433
434        except Exception as e:
435            if pkgmgr in module.params['manager']:
436                module.warn('Failed to retrieve packages with %s: %s' % (pkgmgr, to_text(e)))
437
438    if found == 0:
439        msg = ('Could not detect a supported package manager from the following list: %s, '
440               'or the required Python library is not installed. Check warnings for details.' % managers)
441        module.fail_json(msg=msg)
442
443    # Set the facts, this will override the facts in ansible_facts that might exist from previous runs
444    # when using operating system level or distribution package managers
445    results['ansible_facts']['packages'] = packages
446
447    module.exit_json(**results)
448
449
450if __name__ == '__main__':
451    main()
452