Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

scan_packages: made adding package managers easier #49079

Merged
merged 27 commits into from
Mar 6, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelogs/fragments/scan_packages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
minor_changes:
- package_facts, now supports multiple package managers per system.
New systems supported include Gentoo's portage with portage-utils installed, as well as FreeBSD's pkg
83 changes: 83 additions & 0 deletions lib/ansible/module_utils/facts/packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# (c) 2018, Ansible Project
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)

from __future__ import absolute_import, division, print_function
__metaclass__ = type

from abc import ABCMeta, abstractmethod

from ansible.module_utils.six import with_metaclass
from ansible.module_utils.basic import get_all_subclasses
from ansible.module_utils.common.process import get_bin_path


def get_all_pkg_managers():

return dict([(obj.__name__.lower(), obj) for obj in get_all_subclasses(PkgMgr) if obj not in (CLIMgr, LibMgr)])


class PkgMgr(with_metaclass(ABCMeta, object)):

@abstractmethod
def is_available(self):
# This method is supposed to return True/False if the package manager is currently installed/usable
# It can also 'prep' the required systems in the process of detecting availability
pass

@abstractmethod
def list_installed(self):
# This method should return a list of installed packages, each list item will be passed to get_package_details
pass

@abstractmethod
def get_package_details(self, package):
# This takes a 'package' item and returns a dictionary with the package information, name and version are minimal requirements
pass

def get_packages(self):
# Take all of the above and return a dictionary of lists of dictionaries (package = list of installed versions)

installed_packages = {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about

Suggested change
installed_packages = {}
installed_packages = defaultdict(list)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't see why we need to do extra work here

for package in self.list_installed():
package_details = self.get_package_details(package)
if 'source' not in package_details:
package_details['source'] = self.__class__.__name__.lower()
name = package_details['name']
if name not in installed_packages:
installed_packages[name] = [package_details]
else:
installed_packages[name].append(package_details)
return installed_packages


class LibMgr(PkgMgr):

LIB = None

def __init__(self):

self._lib = None
super(LibMgr, self).__init__()

def is_available(self):
bcoca marked this conversation as resolved.
Show resolved Hide resolved
found = False
try:
self._lib = __import__(self.LIB)
bcoca marked this conversation as resolved.
Show resolved Hide resolved
found = True
bcoca marked this conversation as resolved.
Show resolved Hide resolved
except ImportError:
pass
return found


class CLIMgr(PkgMgr):

CLI = None

def __init__(self):

self._cli = None
super(CLIMgr, self).__init__()

def is_available(self):
self._cli = get_bin_path(self.CLI, False)
bcoca marked this conversation as resolved.
Show resolved Hide resolved
return bool(self._cli)
152 changes: 152 additions & 0 deletions lib/ansible/modules/packaging/language/pip_package_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/python
# (c) 2018, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

# started out with AWX's scan_packages module

from __future__ import absolute_import, division, print_function
__metaclass__ = type

ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}

DOCUMENTATION = '''
module: pip_package_info
short_description: pip package information
description:
- Return information about installed pip packages
version_added: "2.8"
options:
clients:
description:
- A list of the pip executables that will be used to get the packages.
They can be supplied with the full path or just the executable name, i.e `pip3.7`.
default: ['pip']
required: False
type: list
requirements:
- The requested pip executables must be installed on the target.
author:
- Matthew Jones (@matburt)
- Brian Coca (@bcoca)
- Adam Miller (@maxamillion)
'''

EXAMPLES = '''
- name: Just get the list from default pip
pip_package_info:

- name: get the facts for default pip, pip2 and pip3.6
pip_package_info:
clients: ['pip', 'pip2', 'pip3.6']

- name: get from specific paths (virtualenvs?)
pip_package_info:
clients: '/home/me/projec42/python/pip3.5'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
clients: '/home/me/projec42/python/pip3.5'
clients: '/home/me/project42/python/pip3.5'

'''

RETURN = '''
packages:
description: a dictionary of installed package data
returned: always
type: dict
contains:
python:
description: A dictionary with each pip client which then contains a list of dicts with python package information
returned: always
type: dict
sample:
"packages": {
"pip": {
"Babel": [
{
"name": "Babel",
"source": "pip",
"version": "2.6.0"
}
],
"Flask": [
{
"name": "Flask",
"source": "pip",
"version": "1.0.2"
}
],
"Flask-SQLAlchemy": [
{
"name": "Flask-SQLAlchemy",
"source": "pip",
"version": "2.3.2"
}
],
"Jinja2": [
{
"name": "Jinja2",
"source": "pip",
"version": "2.10"
}
],
},
}
'''
import json
import os

from ansible.module_utils._text import to_text
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.facts.packages import CLIMgr


class PIP(CLIMgr):

def __init__(self, pip):

self.CLI = pip

def list_installed(self):
global module
rc, out, err = module.run_command([self._cli, 'list', '-l', '--format=json'])
if rc != 0:
raise Exception("Unable to list packages rc=%s : %s" % (rc, err))
return json.loads(out)

def get_package_details(self, package):
package['source'] = self.CLI
return package


def main():

# start work
global module
module = AnsibleModule(argument_spec=dict(clients={'type': 'list', 'default': ['pip']},), supports_check_mode=True)
packages = {}
results = {'packages': {}}
clients = module.params['clients']

found = 0
for pip in clients:

if not os.path.basename(pip).startswith('pip'):
module.warn('Skipping invalid pip client: %s' % (pip))
continue
try:
pip_mgr = PIP(pip)
if pip_mgr.is_available():
found += 1
packages[pip] = pip_mgr.get_packages()
except Exception as e:
module.warn('Failed to retrieve packages with %s: %s' % (pip, to_text(e)))
continue

if found == 0:
module.fail_json(msg='Unable to use any of the supplied pip clients: %s' % clients)

# return info
results['packages'] = packages
module.exit_json(**results)


if __name__ == '__main__':
main()