mirror of https://github.com/ansible/ansible.git
Python interpreter discovery (#50163)
* Python interpreter discovery * No longer blindly default to only `/usr/bin/python` * `ansible_python_interpreter` defaults to `auto_legacy`, which will discover the platform Python interpreter on some platforms (but still favor `/usr/bin/python` if present for backward compatibility). Use `auto` to always use the discovered interpreter, append `_silent` to either value to suppress warnings. * includes new doc utility method `get_versioned_doclink` to generate a major.minor versioned doclink against docs.ansible.com (or some other config-overridden URL) * docs revisions for python interpreter discovery (cherry picked from commit 5b53c0012ab7212304c28fdd24cb33fd8ff755c2) * verify output on some distros, cleanuppull/53099/head
parent
b8a82f5930
commit
4d3a6123d5
@ -0,0 +1,5 @@
|
||||
major_changes:
|
||||
- Python interpreter discovery - The first time a Python module runs on a target, Ansible will attempt to discover the
|
||||
proper default Python interpreter to use for the target platform/version (instead of immediately defaulting to
|
||||
``/usr/bin/python``). You can override this behavior by setting ``ansible_python_interpreter`` or via config. (see
|
||||
https://github.com/ansible/ansible/pull/50163)
|
@ -0,0 +1,51 @@
|
||||
.. _interpreter_discovery:
|
||||
|
||||
Interpreter Discovery
|
||||
=====================
|
||||
|
||||
Most Ansible modules that execute under a POSIX environment require a Python
|
||||
interpreter on the target host. Unless configured otherwise, Ansible will
|
||||
attempt to discover a suitable Python interpreter on each target host
|
||||
the first time a Python module is executed for that host.
|
||||
|
||||
To control the discovery behavior:
|
||||
|
||||
* for individual hosts and groups, use the ``ansible_python_interpreter`` inventory variable
|
||||
* globally, use the ``interpreter_python`` key in the ``[defaults]`` section of ``ansible.cfg``
|
||||
|
||||
Use one of the following values:
|
||||
|
||||
auto_legacy : (default in 2.8)
|
||||
Detects the target OS platform, distribution, and version, then consults a
|
||||
table listing the correct Python interpreter and path for each
|
||||
platform/distribution/version. If an entry is found, and ``/usr/bin/python`` is absent, uses the discovered interpreter (and path). If an entry
|
||||
is found, and ``/usr/bin/python`` is present, uses ``/usr/bin/python``
|
||||
and issues a warning.
|
||||
This exception provides temporary compatibility with previous versions of
|
||||
Ansible that always defaulted to ``/usr/bin/python``, so if you have
|
||||
installed Python and other dependencies at ``usr/bin/python`` on some hosts,
|
||||
Ansible will find and use them with this setting.
|
||||
If no entry is found, or the listed Python is not present on the
|
||||
target host, searches a list of common Python interpreter
|
||||
paths and uses the first one found; also issues a warning that future
|
||||
installation of another Python interpreter could alter the one chosen.
|
||||
|
||||
auto : (future default in 2.12)
|
||||
Detects the target OS platform, distribution, and version, then consults a
|
||||
table listing the correct Python interpreter and path for each
|
||||
platform/distribution/version. If an entry is found, uses the discovered
|
||||
interpreter.
|
||||
If no entry is found, or the listed Python is not present on the
|
||||
target host, searches a list of common Python interpreter
|
||||
paths and uses the first one found; also issues a warning that future
|
||||
installation of another Python interpreter could alter the one chosen.
|
||||
|
||||
auto_legacy_silent
|
||||
Same as ``auto_legacy``, but does not issue warnings.
|
||||
|
||||
auto_silent
|
||||
Same as ``auto``, but does not issue warnings.
|
||||
|
||||
You can still set ``ansible_python_interpreter`` to a specific path at any
|
||||
variable level (for example, in host_vars, in vars files, in playbooks, etc.).
|
||||
Setting a specific path completely disables automatic interpreter discovery; Ansible always uses the path specified.
|
@ -0,0 +1,48 @@
|
||||
# Copyright: (c) 2018 Ansible Project
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
# FUTURE: this could be swapped out for our bundled version of distro to move more complete platform
|
||||
# logic to the targets, so long as we maintain Py2.6 compat and don't need to do any kind of script assembly
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import json
|
||||
import platform
|
||||
import io
|
||||
import os
|
||||
|
||||
|
||||
def read_utf8_file(path, encoding='utf-8'):
|
||||
if not os.access(path, os.R_OK):
|
||||
return None
|
||||
with io.open(path, 'r', encoding=encoding) as fd:
|
||||
content = fd.read()
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def get_platform_info():
|
||||
result = dict(platform_dist_result=[])
|
||||
|
||||
if hasattr(platform, 'dist'):
|
||||
result['platform_dist_result'] = platform.dist()
|
||||
|
||||
osrelease_content = read_utf8_file('/etc/os-release')
|
||||
# try to fall back to /usr/lib/os-release
|
||||
if not osrelease_content:
|
||||
osrelease_content = read_utf8_file('/usr/lib/os-release')
|
||||
|
||||
result['osrelease_content'] = osrelease_content
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
info = get_platform_info()
|
||||
|
||||
print(json.dumps(info))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
@ -0,0 +1,203 @@
|
||||
# Copyright: (c) 2018 Ansible Project
|
||||
# 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
|
||||
|
||||
import bisect
|
||||
import json
|
||||
import pkgutil
|
||||
import re
|
||||
|
||||
from ansible import constants as C
|
||||
from ansible.module_utils._text import to_text
|
||||
from ansible.module_utils.distro import LinuxDistribution
|
||||
from ansible.utils.display import Display
|
||||
from ansible.utils.plugin_docs import get_versioned_doclink
|
||||
from distutils.version import LooseVersion
|
||||
from traceback import format_exc
|
||||
|
||||
display = Display()
|
||||
foundre = re.compile(r'(?s)PLATFORM[\r\n]+(.*)FOUND(.*)ENDFOUND')
|
||||
|
||||
|
||||
class InterpreterDiscoveryRequiredError(Exception):
|
||||
def __init__(self, message, interpreter_name, discovery_mode):
|
||||
super(InterpreterDiscoveryRequiredError, self).__init__(message)
|
||||
self.interpreter_name = interpreter_name
|
||||
self.discovery_mode = discovery_mode
|
||||
|
||||
def __str__(self):
|
||||
return self.message
|
||||
|
||||
def __repr__(self):
|
||||
# TODO: proper repr impl
|
||||
return self.message
|
||||
|
||||
|
||||
def discover_interpreter(action, interpreter_name, discovery_mode, task_vars):
|
||||
# interpreter discovery is a 2-step process with the target. First, we use a simple shell-agnostic bootstrap to
|
||||
# get the system type from uname, and find any random Python that can get us the info we need. For supported
|
||||
# target OS types, we'll dispatch a Python script that calls plaform.dist() (for older platforms, where available)
|
||||
# and brings back /etc/os-release (if present). The proper Python path is looked up in a table of known
|
||||
# distros/versions with included Pythons; if nothing is found, depending on the discovery mode, either the
|
||||
# default fallback of /usr/bin/python is used (if we know it's there), or discovery fails.
|
||||
|
||||
# FUTURE: add logical equivalence for "python3" in the case of py3-only modules?
|
||||
if interpreter_name != 'python':
|
||||
raise ValueError('Interpreter discovery not supported for {0}'.format(interpreter_name))
|
||||
|
||||
host = task_vars.get('inventory_hostname', 'unknown')
|
||||
res = None
|
||||
platform_type = 'unknown'
|
||||
found_interpreters = ['/usr/bin/python'] # fallback value
|
||||
is_auto_legacy = discovery_mode.startswith('auto_legacy')
|
||||
is_silent = discovery_mode.endswith('_silent')
|
||||
|
||||
try:
|
||||
platform_python_map = C.config.get_config_value('INTERPRETER_PYTHON_DISTRO_MAP', variables=task_vars)
|
||||
bootstrap_python_list = C.config.get_config_value('INTERPRETER_PYTHON_FALLBACK', variables=task_vars)
|
||||
|
||||
display.vvv(msg="Attempting {0} interpreter discovery".format(interpreter_name), host=host)
|
||||
|
||||
# not all command -v impls accept a list of commands, so we have to call it once per python
|
||||
command_list = ["command -v '%s'" % py for py in bootstrap_python_list]
|
||||
shell_bootstrap = "echo PLATFORM; uname; echo FOUND; {0}; echo ENDFOUND".format('; '.join(command_list))
|
||||
|
||||
# FUTURE: in most cases we probably don't want to use become, but maybe sometimes we do?
|
||||
res = action._low_level_execute_command(shell_bootstrap, sudoable=False)
|
||||
|
||||
raw_stdout = res.get('stdout', '')
|
||||
|
||||
match = foundre.match(raw_stdout)
|
||||
|
||||
if not match:
|
||||
display.debug('raw interpreter discovery output: {0}'.format(raw_stdout), host=host)
|
||||
raise ValueError('unexpected output from Python interpreter discovery')
|
||||
|
||||
platform_type = match.groups()[0].lower().strip()
|
||||
|
||||
found_interpreters = [interp.strip() for interp in match.groups()[1].splitlines() if interp.startswith('/')]
|
||||
|
||||
display.debug("found interpreters: {0}".format(found_interpreters), host=host)
|
||||
|
||||
if not found_interpreters:
|
||||
action._discovery_warnings.append('No python interpreters found for host {0} (tried {1})'.format(host, bootstrap_python_list))
|
||||
# this is lame, but returning None or throwing an exception is uglier
|
||||
return '/usr/bin/python'
|
||||
|
||||
if platform_type != 'linux':
|
||||
raise NotImplementedError('unsupported platform for extended discovery: {0}'.format(platform_type))
|
||||
|
||||
platform_script = pkgutil.get_data('ansible.executor.discovery', 'python_target.py')
|
||||
|
||||
# FUTURE: respect pipelining setting instead of just if the connection supports it?
|
||||
if action._connection.has_pipelining:
|
||||
res = action._low_level_execute_command(found_interpreters[0], sudoable=False, in_data=platform_script)
|
||||
else:
|
||||
# FUTURE: implement on-disk case (via script action or ?)
|
||||
raise NotImplementedError('pipelining support required for extended interpreter discovery')
|
||||
|
||||
platform_info = json.loads(res.get('stdout'))
|
||||
|
||||
distro, version = _get_linux_distro(platform_info)
|
||||
|
||||
if not distro or not version:
|
||||
raise NotImplementedError('unable to get Linux distribution/version info')
|
||||
|
||||
version_map = platform_python_map.get(distro.lower().strip())
|
||||
if not version_map:
|
||||
raise NotImplementedError('unsupported Linux distribution: {0}'.format(distro))
|
||||
|
||||
platform_interpreter = _version_fuzzy_match(version, version_map)
|
||||
|
||||
# provide a transition period for hosts that were using /usr/bin/python previously (but shouldn't have been)
|
||||
if is_auto_legacy:
|
||||
if platform_interpreter != '/usr/bin/python' and '/usr/bin/python' in found_interpreters:
|
||||
# FIXME: support comments in sivel's deprecation scanner so we can get reminded on this
|
||||
if not is_silent:
|
||||
action._discovery_deprecation_warnings.append(dict(
|
||||
msg="Distribution {0} {1} should use {2}, but is using "
|
||||
"/usr/bin/python for backward compatibility with prior Ansible releases. "
|
||||
"A future Ansible release will default to using the discovered platform "
|
||||
"python for this host. See {3} for more information"
|
||||
.format(distro, version, platform_interpreter,
|
||||
get_versioned_doclink('reference_appendices/interpreter_discovery.html')),
|
||||
version='2.12'))
|
||||
return '/usr/bin/python'
|
||||
|
||||
if platform_interpreter not in found_interpreters:
|
||||
if platform_interpreter not in bootstrap_python_list:
|
||||
# sanity check to make sure we looked for it
|
||||
if not is_silent:
|
||||
action._discovery_warnings \
|
||||
.append("Platform interpreter {0} is missing from bootstrap list"
|
||||
.format(platform_interpreter))
|
||||
|
||||
if not is_silent:
|
||||
action._discovery_warnings \
|
||||
.append("Distribution {0} {1} should use {2}, but is using {3}, since the "
|
||||
"discovered platform python interpreter was not present. See {4} "
|
||||
"for more information."
|
||||
.format(distro, version, platform_interpreter, found_interpreters[0],
|
||||
get_versioned_doclink('reference_appendices/interpreter_discovery.html')))
|
||||
return found_interpreters[0]
|
||||
|
||||
return platform_interpreter
|
||||
except NotImplementedError as ex:
|
||||
display.vvv(msg='Python interpreter discovery fallback ({0})'.format(to_text(ex)), host=host)
|
||||
except Exception as ex:
|
||||
if not is_silent:
|
||||
display.warning(msg='Unhandled error in Python interpreter discovery for host {0}: {1}'.format(host, to_text(ex)))
|
||||
display.debug(msg='Interpreter discovery traceback:\n{0}'.format(to_text(format_exc())), host=host)
|
||||
if res and res.get('stderr'):
|
||||
display.vvv(msg='Interpreter discovery remote stderr:\n{0}'.format(to_text(res.get('stderr'))), host=host)
|
||||
|
||||
if not is_silent:
|
||||
action._discovery_warnings \
|
||||
.append("Platform {0} is using the discovered Python interpreter at {1}, but future installation of "
|
||||
"another Python interpreter could change this. See {2} "
|
||||
"for more information."
|
||||
.format(platform_type, found_interpreters[0],
|
||||
get_versioned_doclink('reference_appendices/interpreter_discovery.html')))
|
||||
return found_interpreters[0]
|
||||
|
||||
|
||||
def _get_linux_distro(platform_info):
|
||||
dist_result = platform_info.get('platform_dist_result', [])
|
||||
|
||||
if len(dist_result) == 3 and any(dist_result):
|
||||
return dist_result[0], dist_result[1]
|
||||
|
||||
osrelease_content = platform_info.get('osrelease_content')
|
||||
|
||||
if not osrelease_content:
|
||||
return '', ''
|
||||
|
||||
osr = LinuxDistribution._parse_os_release_content(osrelease_content)
|
||||
|
||||
return osr.get('id', ''), osr.get('version_id', '')
|
||||
|
||||
|
||||
def _version_fuzzy_match(version, version_map):
|
||||
# try exact match first
|
||||
res = version_map.get(version)
|
||||
if res:
|
||||
return res
|
||||
|
||||
sorted_looseversions = sorted([LooseVersion(v) for v in version_map.keys()])
|
||||
|
||||
find_looseversion = LooseVersion(version)
|
||||
|
||||
# slot match; return nearest previous version we're newer than
|
||||
kpos = bisect.bisect(sorted_looseversions, find_looseversion)
|
||||
|
||||
if kpos == 0:
|
||||
# older than everything in the list, return the oldest version
|
||||
# TODO: warning-worthy?
|
||||
return version_map.get(sorted_looseversions[0].vstring)
|
||||
|
||||
# TODO: is "past the end of the list" warning-worthy too (at least if it's not a major version match)?
|
||||
|
||||
# return the next-oldest entry that we're newer than...
|
||||
return version_map.get(sorted_looseversions[kpos - 1].vstring)
|
@ -0,0 +1,2 @@
|
||||
shippable/posix/group1
|
||||
non_local # workaround to allow override of ansible_python_interpreter; disables coverage on this integration target
|
@ -0,0 +1,29 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# (c) 2012, Michael DeHaan <michael.dehaan@gmail.com>
|
||||
# (c) 2016, Toshio Kuratomi <tkuratomi@ansible.com>
|
||||
# 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
|
||||
|
||||
import sys
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
|
||||
def main():
|
||||
result = dict(changed=False)
|
||||
|
||||
module = AnsibleModule(argument_spec=dict(
|
||||
facts=dict(type=dict, default={})
|
||||
))
|
||||
|
||||
result['ansible_facts'] = module.params['facts']
|
||||
result['running_python_interpreter'] = sys.executable
|
||||
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
@ -0,0 +1,145 @@
|
||||
- name: ensure we can override ansible_python_interpreter
|
||||
vars:
|
||||
ansible_python_interpreter: overriddenpython
|
||||
assert:
|
||||
that:
|
||||
- ansible_python_interpreter == 'overriddenpython'
|
||||
fail_msg: "'ansible_python_interpreter' appears to be set at a high precedence to {{ ansible_python_interpreter }},
|
||||
which breaks this test."
|
||||
|
||||
- name: snag some facts to validate for later
|
||||
set_fact:
|
||||
distro: '{{ ansible_distribution | default("unknown") | lower }}'
|
||||
distro_version: '{{ ansible_distribution_version | default("unknown") }}'
|
||||
os_family: '{{ ansible_os_family | default("unknown") }}'
|
||||
|
||||
- name: test that python discovery is working and that fact persistence makes it only run once
|
||||
block:
|
||||
- name: clear facts to force interpreter discovery to run
|
||||
meta: clear_facts
|
||||
|
||||
- name: trigger discovery with auto
|
||||
vars:
|
||||
ansible_python_interpreter: auto
|
||||
ping:
|
||||
register: auto_out
|
||||
|
||||
- name: get the interpreter being used on the target to execute modules
|
||||
vars:
|
||||
# keep this set so we can verify we didn't repeat discovery
|
||||
ansible_python_interpreter: auto
|
||||
test_echo_module:
|
||||
register: echoout
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- auto_out.ansible_facts.discovered_interpreter_python is defined
|
||||
- echoout.running_python_interpreter == auto_out.ansible_facts.discovered_interpreter_python
|
||||
# verify that discovery didn't run again (if it did, we'd have the fact in the result)
|
||||
- echoout.ansible_facts is not defined or echoout.ansible_facts.discovered_interpreter_python is not defined
|
||||
|
||||
|
||||
- name: test that auto_legacy gives a dep warning when /usr/bin/python present but != auto result
|
||||
block:
|
||||
- name: clear facts to force interpreter discovery to run
|
||||
meta: clear_facts
|
||||
|
||||
- name: trigger discovery with auto_legacy
|
||||
vars:
|
||||
ansible_python_interpreter: auto_legacy
|
||||
ping:
|
||||
register: legacy
|
||||
|
||||
- name: check for dep warning (only on platforms where auto result is not /usr/bin/python and legacy is)
|
||||
assert:
|
||||
that:
|
||||
- legacy.deprecations | default([]) | length > 0
|
||||
# only check for a dep warning if legacy returned /usr/bin/python and auto didn't
|
||||
when: legacy.ansible_facts.discovered_interpreter_python == '/usr/bin/python' and
|
||||
auto_out.ansible_facts.discovered_interpreter_python != '/usr/bin/python'
|
||||
|
||||
|
||||
- name: test that auto_silent never warns and got the same answer as auto
|
||||
block:
|
||||
- name: clear facts to force interpreter discovery to run
|
||||
meta: clear_facts
|
||||
|
||||
- name: initial task to trigger discovery
|
||||
vars:
|
||||
ansible_python_interpreter: auto_silent
|
||||
ping:
|
||||
register: auto_silent_out
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- auto_silent_out.warnings is not defined
|
||||
- auto_silent_out.ansible_facts.discovered_interpreter_python == auto_out.ansible_facts.discovered_interpreter_python
|
||||
|
||||
|
||||
- name: test that auto_legacy_silent never warns and got the same answer as auto_legacy
|
||||
block:
|
||||
- name: clear facts to force interpreter discovery to run
|
||||
meta: clear_facts
|
||||
|
||||
- name: trigger discovery with auto_legacy_silent
|
||||
vars:
|
||||
ansible_python_interpreter: auto_legacy_silent
|
||||
ping:
|
||||
register: legacy_silent
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- legacy_silent.warnings is not defined
|
||||
- legacy_silent.ansible_facts.discovered_interpreter_python == legacy.ansible_facts.discovered_interpreter_python
|
||||
|
||||
- name: ensure modules can't set discovered_interpreter_X or ansible_X_interpreter
|
||||
block:
|
||||
- test_echo_module:
|
||||
facts:
|
||||
ansible_discovered_interpreter_bogus: from module
|
||||
discovered_interpreter_bogus: from_module
|
||||
ansible_bogus_interpreter: from_module
|
||||
test_fact: from_module
|
||||
register: echoout
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- test_fact == 'from_module'
|
||||
- discovered_interpreter_bogus | default('nope') == 'nope'
|
||||
- ansible_bogus_interpreter | default('nope') == 'nope'
|
||||
# this one will exist in facts, but with its prefix removed
|
||||
- ansible_facts['ansible_bogus_interpreter'] | default('nope') == 'nope'
|
||||
- ansible_facts['discovered_interpreter_bogus'] | default('nope') == 'nope'
|
||||
|
||||
- name: fedora assertions
|
||||
assert:
|
||||
that:
|
||||
- auto_out.ansible_facts.discovered_interpreter_python == '/usr/bin/python3'
|
||||
when: distro == 'fedora' and distro_version is version('23', '>=')
|
||||
|
||||
- name: rhel assertions
|
||||
assert:
|
||||
that:
|
||||
# rhel 6/7
|
||||
- (auto_out.ansible_facts.discovered_interpreter_python == '/usr/bin/python' and distro_version is version('8','<')) or distro_version is version('8','>=')
|
||||
# rhel 8+
|
||||
- (auto_out.ansible_facts.discovered_interpreter_python == '/usr/libexec/platform-python' and distro_version is version('8','>=')) or distro_version is version('8','<')
|
||||
when: distro == 'redhat'
|
||||
|
||||
- name: ubuntu assertions
|
||||
assert:
|
||||
that:
|
||||
# ubuntu < 16
|
||||
- (auto_out.ansible_facts.discovered_interpreter_python == '/usr/bin/python' and distro_version is version('16.04','<')) or distro_version is version('16.04','>=')
|
||||
# ubuntu >= 16
|
||||
- (auto_out.ansible_facts.discovered_interpreter_python == '/usr/bin/python3' and distro_version is version('16.04','>=')) or distro_version is version('16.04','<')
|
||||
when: distro == 'ubuntu'
|
||||
|
||||
- name: mac assertions
|
||||
assert:
|
||||
that:
|
||||
- auto_out.ansible_facts.discovered_interpreter_python == '/usr/bin/python'
|
||||
when: os_family == 'darwin'
|
||||
|
||||
always:
|
||||
- meta: clear_facts
|
Loading…
Reference in New Issue