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