Last active
August 10, 2024 18:05
-
-
Save jikamens/d3e90a63a025ea430956d53fe50a4a43 to your computer and use it in GitHub Desktop.
Ansible module to figure out from where to install Perl modules
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python | |
# Copyright (c) 2023, Jonathan Kamens <[email protected]> | |
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) | |
# SPDX-License-Identifier: GPL-3.0-or-later | |
# pylint: disable=missing-module-docstring | |
from __future__ import (absolute_import, division, print_function) | |
__metaclass__ = type | |
DOCUMENTATION = r''' | |
--- | |
module: perlmod_install_info | |
short_description: Determine from where to install Perl modules | |
requirements: | |
- C(apt-file) executable in search path on systems that use the apt package | |
manager | |
- C(dnf) or C(yum) executable in search path on systems that use the | |
dnf/yum package manager | |
- C(cpanm) executable in search path if you want to be able to search for | |
packages using cpanm | |
version_added: "6.6.0" | |
description: | |
- Searches dnf, yum, apt, and/or cpanm to determine the best source from | |
which to install a Perl module. | |
- Prefers the OS repositories over cpanm. | |
- Specify module names as you would specify them with the C(use) command in | |
a Perl script. | |
- Does not actually install modules. Instead, returns information about | |
where they can be installed from, which can be supplied to subsequent | |
tasks to do the actual installation. | |
- Note that this module will not fail by default if it cannot locate a | |
requested module. If you want that behavior, include a C(failed_when) | |
which checks for C(missing) being non-empty. | |
options: | |
name: | |
description: Specify one or more Perl modules to search for. | |
required: true | |
type: list | |
elements: str | |
try_installed: | |
description: Specify whether to check if modules are already installed | |
and not look elsewhere if they are. | |
type: bool | |
default: true | |
try_dnf: | |
description: Specify whether to check for modules using the dnf package | |
manager. Defaults to true if dnf executable is available. | |
type: str | |
choices: | |
- 'auto' | |
- 'true' | |
- 'false' | |
default: 'auto' | |
try_yum: | |
description: Specify whether to check for modules using the dnf package | |
manager. Defaults to true if C(try_dnf) is false and yum executable | |
is available. | |
type: str | |
choices: | |
- 'auto' | |
- 'true' | |
- 'false' | |
default: 'auto' | |
try_apt: | |
description: Specify whether to check for modules using the apt package | |
manager. Defaults to true if apt-file executable is available. | |
type: str | |
choices: | |
- 'auto' | |
- 'true' | |
- 'false' | |
default: 'auto' | |
try_cpanm: | |
description: specify whether to check for modules using cpanm. Defaults | |
to true if cpanm executable is available. | |
type: str | |
choices: | |
- 'auto' | |
- 'true' | |
- 'false' | |
default: 'auto' | |
update: | |
description: Specify whether to update package manager databases before | |
searching. | |
type: bool | |
default: false | |
author: | |
- Jonathan Kamens (@jikamens) | |
''' | |
EXAMPLES = r''' | |
# Search and fail if the package can't be found | |
- name: Search for Net::DNS if it isn't already installed | |
perlmod_install_info: | |
name: Net::DNS | |
register: perlmod_info | |
failed_when: perlmod_info.missing is defined | |
- name: Search for two modules, even if they're already installed | |
perlmod_install_info: | |
name: | |
- URI | |
- WWW::Mechanize | |
try_installed: false | |
register: perlmod_info | |
- name: install dnf packages identified by perlmod_install_info | |
dnf: | |
name: "{{perlmod_info.dnf}}" | |
when: perlmod_info.dnf is defined | |
- name: install yum packages identified by perlmod_install_info | |
yum: | |
name: "{{perlmod_info.yum}}" | |
when: perlmod_info.yum is defined | |
- name: install yum packages identified by perlmod_install_info | |
apt: | |
name: "{{perlmod_info.apt}}" | |
when: perlmod_info.apt is defined | |
- name: install cpanm packages identified by perlmod_install_info | |
cpanm: | |
name: "{{item}}" | |
with_items: "{{perlmod_info.cpanm}}" | |
when: perlmod_info.cpanm is defined | |
''' | |
RETURN = r''' | |
installed: | |
description: List of modules that are already installed | |
type: list | |
elements: str | |
returned: when C(try_installed) is true and installed modules were found | |
sample: ['Net::DNS'] | |
dnf: | |
description: List of dnf requirements that should be installed to provide | |
at least some of the required Perl modules | |
type: list | |
elements: str | |
returned: when C(try_dnf) is true and requested modules were found in dnf | |
sample: ['perl(Net::DNS)'] | |
yum: | |
description: List of yum requirements that should be installed to provide | |
at least some of the required Perl modules | |
type: list | |
elements: str | |
returned: when C(try_yum) is true and requested modules were found in yum | |
sample: ['perl(Net::DNS)'] | |
apt: | |
description: List of apt packages that should be installed to provide at | |
least some of the required Perl modules | |
type: list | |
elements: str | |
returned: when C(try_apt) is true and requested modules were found in apt | |
sample: ['libnet-dns-perl'] | |
cpanm: | |
description: List of modules that should be installed via CPAN | |
type: list | |
elements: str | |
returned: when C(try_cpanm) is true and requested modules were found in | |
CPAN and nowhere else | |
sample: ['Net::DNS'] | |
missing: | |
description: List of Perl modules that could not be found | |
type: list | |
elements: str | |
returned: when there are missing modules | |
sample: ['No::Such::Module'] | |
''' | |
import distutils.spawn | |
import re | |
import shutil | |
import tempfile | |
from ansible.module_utils.basic import AnsibleModule | |
from ansible.module_utils.parsing.convert_bool import boolean | |
def check_installed(amodule, perlmod): | |
"""Returns True if perl can import the specified module.""" | |
(rc, stdout, stderr) = amodule.run_command( | |
["perl", "-e", "use %s" % (perlmod,)]) | |
return rc == 0 | |
def dnf_or_yum(amodule, which_cmd, update, modules): | |
"""Returns two sets: dnf/yum found modules and the packages they're in.""" | |
want = ["perl(%s)" % (module,) for module in modules] | |
cmd = [which_cmd] | |
if update: | |
cmd.append('--refresh') | |
cmd.append('whatprovides') | |
cmd.extend(want) | |
(rc, stdout, stderr) = amodule.run_command(cmd) | |
packages = set() | |
for line in stdout.split('\n'): | |
line = re.split(r'\s+', line) | |
# Provide : perl(module) = version is output format | |
if len(line) > 2 and line[0] == 'Provide' and line[1] == ':' and \ | |
line[2] not in packages: | |
packages.add(line[2]) | |
found = set(package[5:-1] for package in packages) | |
return (found, packages) | |
def apt(amodule, update, modules): | |
"""Returns two sets: apt found modules and the packages they're in.""" | |
# Update database if requested | |
if update: | |
amodule.run_command(['apt-file', 'update']) | |
# Get list of include paths from perl | |
(rc, stdout, stderr) = amodule.run_command(['perl', '-V']) | |
in_inc = False | |
perlpath = [] | |
for line in (r.strip() for r in stdout.split('\n')): | |
if line == '@INC:': | |
in_inc = True | |
continue | |
if not in_inc: | |
continue | |
if line.startswith('/'): | |
perlpath.append(line) | |
continue | |
break | |
found = set() | |
packages = set() | |
for name in modules: | |
# Convert module name into tail end of a module path | |
tail = '/' + name.replace('::', '/') + '.pm' | |
# Search for it with apt-file | |
(rc, stdout, stderr) = amodule.run_command( | |
['apt-file', 'search', tail]) | |
for line in stdout.split('\n'): | |
# package: path is output format | |
match = re.match(r'^([^:]+): (/.*)', line) | |
if not match: | |
continue | |
package = match.group(1) | |
path = match.group(2) | |
# Does the path end with our module path (eliminate accidental | |
# matches in the middle of the path) | |
if not path.endswith(tail): | |
continue | |
directory = path[:-len(tail)] | |
if directory not in perlpath: | |
continue | |
# Eureka | |
found.add(name) | |
packages.add(package) | |
return (found, packages) | |
def cpanm(amodule, modules): | |
"""Returns 2 sets: CPANM modules found and their recursive dependencies.""" | |
found = set() | |
dependencies = set() | |
for module in modules: | |
# It would be better to use TemporaryDirectory rather htan mkdtemp, | |
# but it's not supported in Python 2.7, which Ansible still needs ot | |
# support. | |
tempdir = tempfile.mkdtemp() | |
try: | |
(rc, stdout, stderr) = amodule.run_command( | |
['cpanm', '--local-lib-contained', | |
tempdir, '--scandeps', module]) | |
finally: | |
shutil.rmtree(tempdir, ignore_errors=True) | |
if rc != 0: | |
continue | |
found.add(module) | |
for line in stdout.split('\n'): | |
match = re.search(r'Found dependencies: (.*)', line) | |
if not match: | |
continue | |
dependencies |= set(m for m in match.group(1).split(', ') | |
if m not in modules) | |
return (found, dependencies) | |
def find_modules(amodule, result, errors, names=None, update=None): | |
"""Stores results in `result`, returns set of missing modules.""" | |
params = amodule.params | |
names = set(params['name'] if names is None else names) | |
update = params['update'] if update is None else update | |
try_installed = params['try_installed'] | |
# It would probably be better to use shutil.which instead of | |
# distutils.spawn.find_executable, but the former isn't supported in | |
# Python 2.7, which Ansible still needs to support. | |
try_dnf = distutils.spawn.find_executable('dnf') is not None \ | |
if params['try_dnf'] == 'auto' else boolean(params['try_dnf']) | |
if not try_dnf: | |
try_yum = distutils.spawn.find_executable('yum') is not None \ | |
if params['try_yum'] == 'auto' else boolean(params['try_yum']) | |
try_apt = distutils.spawn.find_executable('apt-file') is not None \ | |
if params['try_apt'] == 'auto' else boolean(params['try_apt']) | |
try_cpanm = distutils.spawn.find_executable('cpanm') is not None \ | |
if params['try_cpanm'] == 'auto' else boolean(params['try_cpanm']) | |
found = set() | |
if names and try_installed: | |
this_found = set(n for n in names if check_installed(amodule, n)) | |
result['installed'] = result.get('installed', set()) | this_found | |
found |= this_found | |
names -= this_found | |
if names and (try_dnf or try_yum): | |
which_cmd = 'dnf' if try_dnf else 'yum' | |
(this_found, packages) = dnf_or_yum(amodule, which_cmd, update, names) | |
result[which_cmd] = result.get(which_cmd, set()) | packages | |
found |= this_found | |
names -= this_found | |
if names and try_apt: | |
(this_found, packages) = apt(amodule, update, names) | |
result['apt'] = result.get('apt', set()) | packages | |
found |= this_found | |
names -= this_found | |
if names and try_cpanm: | |
(this_found, dependencies) = cpanm(amodule, names) | |
result['cpanm'] = result.get('cpanm', set()) | this_found | |
found |= this_found | |
names -= this_found | |
dependencies -= found | |
names |= find_modules(amodule, result, errors, names=dependencies, | |
update=False) | |
return names | |
def run_module(): | |
"""Does all the work.""" | |
module_args = dict( | |
name=dict(type='list', elements='str', required=True), | |
try_installed=dict(type='bool', required=False, default=True), | |
try_dnf=dict(type='str', required=False, default='auto', | |
choices=['auto', 'true', 'false']), | |
try_yum=dict(type='str', required=False, default='auto', | |
choices=['auto', 'true', 'false']), | |
try_apt=dict(type='str', required=False, default='auto', | |
choices=['auto', 'true', 'false']), | |
try_cpanm=dict(type='str', required=False, default='auto', | |
choices=['auto', 'true', 'false']), | |
update=dict(type='bool', required=False, default=False), | |
) | |
result = dict( | |
changed=False, | |
) | |
module = AnsibleModule( | |
argument_spec=module_args, | |
supports_check_mode=True | |
) | |
errors = [] | |
missing = find_modules(module, result, errors) | |
if missing: | |
result['missing'] = missing | |
if errors: | |
module.fail_json(msg='\n'.join(errors)) | |
module.exit_json(**result) | |
def main(): | |
"""Main entry point when called as script.""" | |
run_module() | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment