Created
March 9, 2023 17:12
-
-
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 <jik@kamens.us> | |
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) | |
from __future__ import (absolute_import, division, print_function) | |
__metaclass__ = type | |
DOCUMENTATION = r''' | |
--- | |
module: find_perl_module | |
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 | |
# If this is part of a collection, you need to use semantic versioning, | |
# i.e. the version is of the form "2.5.0" and not "2.4". | |
version_added: "1.0.0" | |
description: This is my longer description explaining my test module. | |
- 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 Perl module. If you want that behavior, include a C(failed_when) which checks for C(missing) being non-empty. | |
options: | |
name: | |
description: One or more modules to search for | |
required: true | |
type: list | |
elements: str | |
try_installed: | |
description: Whether to check if modules are already installed and not look elsewhere if they are | |
type: bool | |
default: true | |
try_dnf: | |
description: Whether to check for modules using the dnf package manager | |
default: true if dnf executable is available | |
try_yum: | |
description: Whether to check for modules using the dnf package manager | |
default: true if C(try_dnf) is false and yum executable is available | |
try_apt: | |
description: Whether to check for modules using the apt package manager | |
default: true if apt-file executable is available | |
try_cpanm: | |
description: Whether to check for modules using cpanm | |
default: true if cpanm executable is available | |
update: | |
description: Whether to update package manager databases before searching | |
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 | |
find_perl_module: name=Net::DNS | |
register: fpm | |
failed_when: "'missing' in fpm" | |
- name: Search for two modules, even if they're already installed | |
find_perl_module: | |
name: | |
- URI | |
- WWW::Mechanize | |
try_installed: false | |
register: fpm | |
# Install packages identified by this module | |
- dnf: name={{fpm.dnf}} | |
when: "'dnf' in fpm" | |
- yum: name={{fpm.yum}} | |
when: "'dnf' in fpm" | |
- apt: name={{fpm.apt}} | |
when: "'apt' in fpm" | |
- cpanm: name={{item}} | |
with_items: "{{fpm.cpanm}}" | |
when: "'cpanm' in fpm" | |
''' | |
RETURN = r''' | |
# These are examples of possible return values, and in general should use other names for return values. | |
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'] | |
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'] | |
''' | |
from ansible.module_utils.basic import AnsibleModule | |
import re | |
import shutil | |
import subprocess | |
import tempfile | |
def check_installed(module): | |
result = subprocess.run(('perl', '-e', f'use {module}'), | |
capture_output=True) | |
return result.returncode == 0 | |
def dnf_or_yum(which_cmd, update, modules): | |
want = [f'perl({module})' for module in modules] | |
cmd = [which_cmd] | |
if update: | |
cmd.append('--refresh') | |
cmd.append('whatprovides') | |
cmd.extend(want) | |
result = subprocess.run(cmd, capture_output=True, encoding='ascii') | |
packages = set() | |
for line in result.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(update, modules): | |
# Update database if requested | |
if update: | |
subprocess.run(('apt-file', 'update'), capture_output=True) | |
# Get list of include paths from perl | |
result = subprocess.run(('perl', '-V'), capture_output=True, | |
encoding='ascii') | |
result = result.stdout.split('\n') | |
in_inc = False | |
perlpath = [] | |
for line in (r.strip() for r in result): | |
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 | |
result = subprocess.run(('apt-file', 'search', tail), | |
capture_output=True, encoding='ascii') | |
for line in result.stdout.split('\n'): | |
# package: path is output format | |
match = re.match(r'^([^:]+): (/.*)', line) | |
if not match: | |
continue | |
package = match[1] | |
path = match[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(modules): | |
found = set() | |
dependencies = set() | |
for module in modules: | |
with tempfile.TemporaryDirectory() as tempdir: | |
result = subprocess.run(('cpanm', '--local-lib-contained', | |
tempdir, '--scandeps', module), | |
capture_output=True, encoding='ascii') | |
if result.returncode != 0: | |
next | |
found.add(module) | |
for line in result.stdout.split('\n'): | |
match = re.search(r'Found dependencies: (.*)', line) | |
if not match: | |
continue | |
dependencies |= set(m for m in match[1].split(', ') | |
if m not in modules) | |
return(found, dependencies) | |
def find_modules(params, result, errors, names=None, update=None): | |
names = set(params['name'] if names is None else names) | |
update = params['update'] if update is None else update | |
try_installed = params['try_installed'] | |
try_dnf = shutil.which('dnf') is not None \ | |
if params['try_dnf'] is None \ | |
else params['try_dnf'] | |
if not try_dnf: | |
try_yum = shutil.which('yum') is not None \ | |
if params['try_yum'] is None \ | |
else params['try_yum'] | |
try_apt = shutil.which('apt-file') is not None \ | |
if params['try_apt'] is None \ | |
else params['try_apt'] | |
try_cpanm = shutil.which('cpanm') is not None \ | |
if params['try_cpanm'] is None \ | |
else params['cpanm'] | |
found = set() | |
if names and try_installed: | |
this_found = set(n for n in names if check_installed(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(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(update, names) | |
result['apt'] = result.get('apt', set()) | packages | |
found |= this_found | |
names -= this_found | |
if names and try_cpanm: | |
(this_found, dependencies) = cpanm(names) | |
result['cpanm'] = result.get('cpanm', set()) | this_found | |
found |= this_found | |
names -= this_found | |
dependencies -= found | |
names |= find_modules(params, result, errors, names=dependencies, | |
update=False) | |
return(names) | |
def run_module(): | |
module_args = dict( | |
name=dict(type='list', elements='str', required=True), | |
try_installed=dict(type='bool', required=False, default=True), | |
try_dnf=dict(type='bool', required=False, default=None), | |
try_yum=dict(type='bool', required=False, default=None), | |
try_apt=dict(type='bool', required=False, default=None), | |
try_cpanm=dict(type='bool', required=False, default=None), | |
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.params, result, errors) | |
if missing: | |
result['missing'] = missing | |
if errors: | |
module.fail_json(msg='\n'.join(errors)) | |
module.exit_json(**result) | |
def main(): | |
run_module() | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment