1# Copyright: (c) 2018 Ansible Project 2# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 3 4from __future__ import (absolute_import, division, print_function) 5__metaclass__ = type 6 7import bisect 8import json 9import pkgutil 10import re 11 12from ansible import constants as C 13from ansible.module_utils._text import to_native, to_text 14from ansible.module_utils.distro import LinuxDistribution 15from ansible.utils.display import Display 16from ansible.utils.plugin_docs import get_versioned_doclink 17from distutils.version import LooseVersion 18from traceback import format_exc 19 20display = Display() 21foundre = re.compile(r'(?s)PLATFORM[\r\n]+(.*)FOUND(.*)ENDFOUND') 22 23 24class InterpreterDiscoveryRequiredError(Exception): 25 def __init__(self, message, interpreter_name, discovery_mode): 26 super(InterpreterDiscoveryRequiredError, self).__init__(message) 27 self.interpreter_name = interpreter_name 28 self.discovery_mode = discovery_mode 29 30 def __str__(self): 31 return self.message 32 33 def __repr__(self): 34 # TODO: proper repr impl 35 return self.message 36 37 38def discover_interpreter(action, interpreter_name, discovery_mode, task_vars): 39 # interpreter discovery is a 2-step process with the target. First, we use a simple shell-agnostic bootstrap to 40 # get the system type from uname, and find any random Python that can get us the info we need. For supported 41 # target OS types, we'll dispatch a Python script that calls plaform.dist() (for older platforms, where available) 42 # and brings back /etc/os-release (if present). The proper Python path is looked up in a table of known 43 # distros/versions with included Pythons; if nothing is found, depending on the discovery mode, either the 44 # default fallback of /usr/bin/python is used (if we know it's there), or discovery fails. 45 46 # FUTURE: add logical equivalence for "python3" in the case of py3-only modules? 47 if interpreter_name != 'python': 48 raise ValueError('Interpreter discovery not supported for {0}'.format(interpreter_name)) 49 50 host = task_vars.get('inventory_hostname', 'unknown') 51 res = None 52 platform_type = 'unknown' 53 found_interpreters = [u'/usr/bin/python'] # fallback value 54 is_auto_legacy = discovery_mode.startswith('auto_legacy') 55 is_silent = discovery_mode.endswith('_silent') 56 57 try: 58 platform_python_map = C.config.get_config_value('INTERPRETER_PYTHON_DISTRO_MAP', variables=task_vars) 59 bootstrap_python_list = C.config.get_config_value('INTERPRETER_PYTHON_FALLBACK', variables=task_vars) 60 61 display.vvv(msg=u"Attempting {0} interpreter discovery".format(interpreter_name), host=host) 62 63 # not all command -v impls accept a list of commands, so we have to call it once per python 64 command_list = ["command -v '%s'" % py for py in bootstrap_python_list] 65 shell_bootstrap = "echo PLATFORM; uname; echo FOUND; {0}; echo ENDFOUND".format('; '.join(command_list)) 66 67 # FUTURE: in most cases we probably don't want to use become, but maybe sometimes we do? 68 res = action._low_level_execute_command(shell_bootstrap, sudoable=False) 69 70 raw_stdout = res.get('stdout', u'') 71 72 match = foundre.match(raw_stdout) 73 74 if not match: 75 display.debug(u'raw interpreter discovery output: {0}'.format(raw_stdout), host=host) 76 raise ValueError('unexpected output from Python interpreter discovery') 77 78 platform_type = match.groups()[0].lower().strip() 79 80 found_interpreters = [interp.strip() for interp in match.groups()[1].splitlines() if interp.startswith('/')] 81 82 display.debug(u"found interpreters: {0}".format(found_interpreters), host=host) 83 84 if not found_interpreters: 85 action._discovery_warnings.append(u'No python interpreters found for host {0} (tried {1})'.format(host, bootstrap_python_list)) 86 # this is lame, but returning None or throwing an exception is uglier 87 return u'/usr/bin/python' 88 89 if platform_type != 'linux': 90 raise NotImplementedError('unsupported platform for extended discovery: {0}'.format(to_native(platform_type))) 91 92 platform_script = pkgutil.get_data('ansible.executor.discovery', 'python_target.py') 93 94 # FUTURE: respect pipelining setting instead of just if the connection supports it? 95 if action._connection.has_pipelining: 96 res = action._low_level_execute_command(found_interpreters[0], sudoable=False, in_data=platform_script) 97 else: 98 # FUTURE: implement on-disk case (via script action or ?) 99 raise NotImplementedError('pipelining support required for extended interpreter discovery') 100 101 platform_info = json.loads(res.get('stdout')) 102 103 distro, version = _get_linux_distro(platform_info) 104 105 if not distro or not version: 106 raise NotImplementedError('unable to get Linux distribution/version info') 107 108 version_map = platform_python_map.get(distro.lower().strip()) 109 if not version_map: 110 raise NotImplementedError('unsupported Linux distribution: {0}'.format(distro)) 111 112 platform_interpreter = to_text(_version_fuzzy_match(version, version_map), errors='surrogate_or_strict') 113 114 # provide a transition period for hosts that were using /usr/bin/python previously (but shouldn't have been) 115 if is_auto_legacy: 116 if platform_interpreter != u'/usr/bin/python' and u'/usr/bin/python' in found_interpreters: 117 # FIXME: support comments in sivel's deprecation scanner so we can get reminded on this 118 if not is_silent: 119 action._discovery_deprecation_warnings.append(dict( 120 msg=u"Distribution {0} {1} on host {2} should use {3}, but is using " 121 u"/usr/bin/python for backward compatibility with prior Ansible releases. " 122 u"A future Ansible release will default to using the discovered platform " 123 u"python for this host. See {4} for more information" 124 .format(distro, version, host, platform_interpreter, 125 get_versioned_doclink('reference_appendices/interpreter_discovery.html')), 126 version='2.12')) 127 return u'/usr/bin/python' 128 129 if platform_interpreter not in found_interpreters: 130 if platform_interpreter not in bootstrap_python_list: 131 # sanity check to make sure we looked for it 132 if not is_silent: 133 action._discovery_warnings \ 134 .append(u"Platform interpreter {0} on host {1} is missing from bootstrap list" 135 .format(platform_interpreter, host)) 136 137 if not is_silent: 138 action._discovery_warnings \ 139 .append(u"Distribution {0} {1} on host {2} should use {3}, but is using {4}, since the " 140 u"discovered platform python interpreter was not present. See {5} " 141 u"for more information." 142 .format(distro, version, host, platform_interpreter, found_interpreters[0], 143 get_versioned_doclink('reference_appendices/interpreter_discovery.html'))) 144 return found_interpreters[0] 145 146 return platform_interpreter 147 except NotImplementedError as ex: 148 display.vvv(msg=u'Python interpreter discovery fallback ({0})'.format(to_text(ex)), host=host) 149 except Exception as ex: 150 if not is_silent: 151 display.warning(msg=u'Unhandled error in Python interpreter discovery for host {0}: {1}'.format(host, to_text(ex))) 152 display.debug(msg=u'Interpreter discovery traceback:\n{0}'.format(to_text(format_exc())), host=host) 153 if res and res.get('stderr'): 154 display.vvv(msg=u'Interpreter discovery remote stderr:\n{0}'.format(to_text(res.get('stderr'))), host=host) 155 156 if not is_silent: 157 action._discovery_warnings \ 158 .append(u"Platform {0} on host {1} is using the discovered Python interpreter at {2}, but future installation of " 159 u"another Python interpreter could change this. See {3} " 160 u"for more information." 161 .format(platform_type, host, found_interpreters[0], 162 get_versioned_doclink('reference_appendices/interpreter_discovery.html'))) 163 return found_interpreters[0] 164 165 166def _get_linux_distro(platform_info): 167 dist_result = platform_info.get('platform_dist_result', []) 168 169 if len(dist_result) == 3 and any(dist_result): 170 return dist_result[0], dist_result[1] 171 172 osrelease_content = platform_info.get('osrelease_content') 173 174 if not osrelease_content: 175 return u'', u'' 176 177 osr = LinuxDistribution._parse_os_release_content(osrelease_content) 178 179 return osr.get('id', u''), osr.get('version_id', u'') 180 181 182def _version_fuzzy_match(version, version_map): 183 # try exact match first 184 res = version_map.get(version) 185 if res: 186 return res 187 188 sorted_looseversions = sorted([LooseVersion(v) for v in version_map.keys()]) 189 190 find_looseversion = LooseVersion(version) 191 192 # slot match; return nearest previous version we're newer than 193 kpos = bisect.bisect(sorted_looseversions, find_looseversion) 194 195 if kpos == 0: 196 # older than everything in the list, return the oldest version 197 # TODO: warning-worthy? 198 return version_map.get(sorted_looseversions[0].vstring) 199 200 # TODO: is "past the end of the list" warning-worthy too (at least if it's not a major version match)? 201 202 # return the next-oldest entry that we're newer than... 203 return version_map.get(sorted_looseversions[kpos - 1].vstring) 204