47b2cb748b
* Allow plugin boolean options to default to None * fix mistake * changelog * be more specific
537 lines
22 KiB
Python
537 lines
22 KiB
Python
# Copyright: (c) 2017, 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 io
|
|
import os
|
|
import os.path
|
|
import sys
|
|
import stat
|
|
import tempfile
|
|
import traceback
|
|
from collections import namedtuple
|
|
|
|
from yaml import load as yaml_load
|
|
try:
|
|
# use C version if possible for speedup
|
|
from yaml import CSafeLoader as SafeLoader
|
|
except ImportError:
|
|
from yaml import SafeLoader
|
|
|
|
from ansible.config.data import ConfigData
|
|
from ansible.errors import AnsibleOptionsError, AnsibleError
|
|
from ansible.module_utils.six import PY3, string_types
|
|
from ansible.module_utils.six.moves import configparser
|
|
from ansible.module_utils._text import to_text, to_bytes, to_native
|
|
from ansible.module_utils.parsing.convert_bool import boolean
|
|
from ansible.parsing.quoting import unquote
|
|
from ansible.utils import py3compat
|
|
from ansible.utils.path import unfrackpath
|
|
from ansible.utils.path import makedirs_safe
|
|
|
|
|
|
Plugin = namedtuple('Plugin', 'name type')
|
|
Setting = namedtuple('Setting', 'name value origin type')
|
|
|
|
INTERNAL_DEFS = {'lookup': ('_terms',)}
|
|
|
|
|
|
def _get_entry(plugin_type, plugin_name, config):
|
|
''' construct entry for requested config '''
|
|
entry = ''
|
|
if plugin_type:
|
|
entry += 'plugin_type: %s ' % plugin_type
|
|
if plugin_name:
|
|
entry += 'plugin: %s ' % plugin_name
|
|
entry += 'setting: %s ' % config
|
|
return entry
|
|
|
|
|
|
# FIXME: see if we can unify in module_utils with similar function used by argspec
|
|
def ensure_type(value, value_type, origin=None):
|
|
''' return a configuration variable with casting
|
|
:arg value: The value to ensure correct typing of
|
|
:kwarg value_type: The type of the value. This can be any of the following strings:
|
|
:boolean: sets the value to a True or False value
|
|
:bool: Same as 'boolean'
|
|
:integer: Sets the value to an integer or raises a ValueType error
|
|
:int: Same as 'integer'
|
|
:float: Sets the value to a float or raises a ValueType error
|
|
:list: Treats the value as a comma separated list. Split the value
|
|
and return it as a python list.
|
|
:none: Sets the value to None
|
|
:path: Expands any environment variables and tilde's in the value.
|
|
:tmppath: Create a unique temporary directory inside of the directory
|
|
specified by value and return its path.
|
|
:temppath: Same as 'tmppath'
|
|
:tmp: Same as 'tmppath'
|
|
:pathlist: Treat the value as a typical PATH string. (On POSIX, this
|
|
means colon separated strings.) Split the value and then expand
|
|
each part for environment variables and tildes.
|
|
:pathspec: Treat the value as a PATH string. Expands any environment variables
|
|
tildes's in the value.
|
|
:str: Sets the value to string types.
|
|
:string: Same as 'str'
|
|
'''
|
|
|
|
basedir = None
|
|
if origin and os.path.isabs(origin) and os.path.exists(to_bytes(origin)):
|
|
basedir = origin
|
|
|
|
if value_type:
|
|
value_type = value_type.lower()
|
|
|
|
if value is not None:
|
|
if value_type in ('boolean', 'bool'):
|
|
value = boolean(value, strict=False)
|
|
|
|
elif value_type in ('integer', 'int'):
|
|
value = int(value)
|
|
|
|
elif value_type == 'float':
|
|
value = float(value)
|
|
|
|
elif value_type == 'list':
|
|
if isinstance(value, string_types):
|
|
value = [x.strip() for x in value.split(',')]
|
|
|
|
elif value_type == 'none':
|
|
if value == "None":
|
|
value = None
|
|
|
|
elif value_type == 'path':
|
|
value = resolve_path(value, basedir=basedir)
|
|
|
|
elif value_type in ('tmp', 'temppath', 'tmppath'):
|
|
value = resolve_path(value, basedir=basedir)
|
|
if not os.path.exists(value):
|
|
makedirs_safe(value, 0o700)
|
|
prefix = 'ansible-local-%s' % os.getpid()
|
|
value = tempfile.mkdtemp(prefix=prefix, dir=value)
|
|
|
|
elif value_type == 'pathspec':
|
|
if isinstance(value, string_types):
|
|
value = value.split(os.pathsep)
|
|
value = [resolve_path(x, basedir=basedir) for x in value]
|
|
|
|
elif value_type == 'pathlist':
|
|
if isinstance(value, string_types):
|
|
value = value.split(',')
|
|
value = [resolve_path(x, basedir=basedir) for x in value]
|
|
|
|
elif value_type in ('str', 'string'):
|
|
value = unquote(to_text(value, errors='surrogate_or_strict'))
|
|
|
|
# defaults to string type
|
|
elif isinstance(value, string_types):
|
|
value = unquote(value)
|
|
|
|
return to_text(value, errors='surrogate_or_strict', nonstring='passthru')
|
|
|
|
|
|
# FIXME: see if this can live in utils/path
|
|
def resolve_path(path, basedir=None):
|
|
''' resolve relative or 'variable' paths '''
|
|
if '{{CWD}}' in path: # allow users to force CWD using 'magic' {{CWD}}
|
|
path = path.replace('{{CWD}}', os.getcwd())
|
|
|
|
return unfrackpath(path, follow=False, basedir=basedir)
|
|
|
|
|
|
# FIXME: generic file type?
|
|
def get_config_type(cfile):
|
|
|
|
ftype = None
|
|
if cfile is not None:
|
|
ext = os.path.splitext(cfile)[-1]
|
|
if ext in ('.ini', '.cfg'):
|
|
ftype = 'ini'
|
|
elif ext in ('.yaml', '.yml'):
|
|
ftype = 'yaml'
|
|
else:
|
|
raise AnsibleOptionsError("Unsupported configuration file extension for %s: %s" % (cfile, to_native(ext)))
|
|
|
|
return ftype
|
|
|
|
|
|
# FIXME: can move to module_utils for use for ini plugins also?
|
|
def get_ini_config_value(p, entry):
|
|
''' returns the value of last ini entry found '''
|
|
value = None
|
|
if p is not None:
|
|
try:
|
|
value = p.get(entry.get('section', 'defaults'), entry.get('key', ''), raw=True)
|
|
except Exception: # FIXME: actually report issues here
|
|
pass
|
|
return value
|
|
|
|
|
|
def find_ini_config_file(warnings=None):
|
|
''' Load INI Config File order(first found is used): ENV, CWD, HOME, /etc/ansible '''
|
|
# FIXME: eventually deprecate ini configs
|
|
|
|
if warnings is None:
|
|
# Note: In this case, warnings does nothing
|
|
warnings = set()
|
|
|
|
# A value that can never be a valid path so that we can tell if ANSIBLE_CONFIG was set later
|
|
# We can't use None because we could set path to None.
|
|
SENTINEL = object
|
|
|
|
potential_paths = []
|
|
|
|
# Environment setting
|
|
path_from_env = os.getenv("ANSIBLE_CONFIG", SENTINEL)
|
|
if path_from_env is not SENTINEL:
|
|
path_from_env = unfrackpath(path_from_env, follow=False)
|
|
if os.path.isdir(to_bytes(path_from_env)):
|
|
path_from_env = os.path.join(path_from_env, "ansible.cfg")
|
|
potential_paths.append(path_from_env)
|
|
|
|
# Current working directory
|
|
warn_cmd_public = False
|
|
try:
|
|
cwd = os.getcwd()
|
|
perms = os.stat(cwd)
|
|
cwd_cfg = os.path.join(cwd, "ansible.cfg")
|
|
if perms.st_mode & stat.S_IWOTH:
|
|
# Working directory is world writable so we'll skip it.
|
|
# Still have to look for a file here, though, so that we know if we have to warn
|
|
if os.path.exists(cwd_cfg):
|
|
warn_cmd_public = True
|
|
else:
|
|
potential_paths.append(cwd_cfg)
|
|
except OSError:
|
|
# If we can't access cwd, we'll simply skip it as a possible config source
|
|
pass
|
|
|
|
# Per user location
|
|
potential_paths.append(unfrackpath("~/.ansible.cfg", follow=False))
|
|
|
|
# System location
|
|
potential_paths.append("/etc/ansible/ansible.cfg")
|
|
|
|
for path in potential_paths:
|
|
if os.path.exists(to_bytes(path)):
|
|
break
|
|
else:
|
|
path = None
|
|
|
|
# Emit a warning if all the following are true:
|
|
# * We did not use a config from ANSIBLE_CONFIG
|
|
# * There's an ansible.cfg in the current working directory that we skipped
|
|
if path_from_env != path and warn_cmd_public:
|
|
warnings.add(u"Ansible is being run in a world writable directory (%s),"
|
|
u" ignoring it as an ansible.cfg source."
|
|
u" For more information see"
|
|
u" https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir"
|
|
% to_text(cwd))
|
|
|
|
return path
|
|
|
|
|
|
class ConfigManager(object):
|
|
|
|
DEPRECATED = []
|
|
WARNINGS = set()
|
|
|
|
def __init__(self, conf_file=None, defs_file=None):
|
|
|
|
self._base_defs = {}
|
|
self._plugins = {}
|
|
self._parsers = {}
|
|
|
|
self._config_file = conf_file
|
|
self.data = ConfigData()
|
|
|
|
self._base_defs = self._read_config_yaml_file(defs_file or ('%s/base.yml' % os.path.dirname(__file__)))
|
|
|
|
if self._config_file is None:
|
|
# set config using ini
|
|
self._config_file = find_ini_config_file(self.WARNINGS)
|
|
|
|
# consume configuration
|
|
if self._config_file:
|
|
if os.path.exists(to_bytes(self._config_file)):
|
|
# initialize parser and read config
|
|
self._parse_config_file()
|
|
|
|
# update constants
|
|
self.update_config_data()
|
|
try:
|
|
self.update_module_defaults_groups()
|
|
except Exception as e:
|
|
# Since this is a 2.7 preview feature, we want to have it fail as gracefully as possible when there are issues.
|
|
sys.stderr.write('Could not load module_defaults_groups: %s: %s\n\n' % (type(e).__name__, e))
|
|
self.module_defaults_groups = {}
|
|
|
|
def _read_config_yaml_file(self, yml_file):
|
|
# TODO: handle relative paths as relative to the directory containing the current playbook instead of CWD
|
|
# Currently this is only used with absolute paths to the `ansible/config` directory
|
|
yml_file = to_bytes(yml_file)
|
|
if os.path.exists(yml_file):
|
|
with open(yml_file, 'rb') as config_def:
|
|
return yaml_load(config_def, Loader=SafeLoader) or {}
|
|
raise AnsibleError(
|
|
"Missing base YAML definition file (bad install?): %s" % to_native(yml_file))
|
|
|
|
def _parse_config_file(self, cfile=None):
|
|
''' return flat configuration settings from file(s) '''
|
|
# TODO: take list of files with merge/nomerge
|
|
|
|
if cfile is None:
|
|
cfile = self._config_file
|
|
|
|
ftype = get_config_type(cfile)
|
|
if cfile is not None:
|
|
if ftype == 'ini':
|
|
self._parsers[cfile] = configparser.ConfigParser()
|
|
with open(to_bytes(cfile), 'rb') as f:
|
|
try:
|
|
cfg_text = to_text(f.read(), errors='surrogate_or_strict')
|
|
except UnicodeError as e:
|
|
raise AnsibleOptionsError("Error reading config file(%s) because the config file was not utf8 encoded: %s" % (cfile, to_native(e)))
|
|
try:
|
|
if PY3:
|
|
self._parsers[cfile].read_string(cfg_text)
|
|
else:
|
|
cfg_file = io.StringIO(cfg_text)
|
|
self._parsers[cfile].readfp(cfg_file)
|
|
except configparser.Error as e:
|
|
raise AnsibleOptionsError("Error reading config file (%s): %s" % (cfile, to_native(e)))
|
|
# FIXME: this should eventually handle yaml config files
|
|
# elif ftype == 'yaml':
|
|
# with open(cfile, 'rb') as config_stream:
|
|
# self._parsers[cfile] = yaml.safe_load(config_stream)
|
|
else:
|
|
raise AnsibleOptionsError("Unsupported configuration file type: %s" % to_native(ftype))
|
|
|
|
def _find_yaml_config_files(self):
|
|
''' Load YAML Config Files in order, check merge flags, keep origin of settings'''
|
|
pass
|
|
|
|
def get_plugin_options(self, plugin_type, name, keys=None, variables=None, direct=None):
|
|
|
|
options = {}
|
|
defs = self.get_configuration_definitions(plugin_type, name)
|
|
for option in defs:
|
|
options[option] = self.get_config_value(option, plugin_type=plugin_type, plugin_name=name, keys=keys, variables=variables, direct=direct)
|
|
|
|
return options
|
|
|
|
def get_plugin_vars(self, plugin_type, name):
|
|
|
|
pvars = []
|
|
for pdef in self.get_configuration_definitions(plugin_type, name).values():
|
|
if 'vars' in pdef and pdef['vars']:
|
|
for var_entry in pdef['vars']:
|
|
pvars.append(var_entry['name'])
|
|
return pvars
|
|
|
|
def get_configuration_definition(self, name, plugin_type=None, plugin_name=None):
|
|
|
|
ret = {}
|
|
if plugin_type is None:
|
|
ret = self._base_defs.get(name, None)
|
|
elif plugin_name is None:
|
|
ret = self._plugins.get(plugin_type, {}).get(name, None)
|
|
else:
|
|
ret = self._plugins.get(plugin_type, {}).get(plugin_name, {}).get(name, None)
|
|
|
|
return ret
|
|
|
|
def get_configuration_definitions(self, plugin_type=None, name=None):
|
|
''' just list the possible settings, either base or for specific plugins or plugin '''
|
|
|
|
ret = {}
|
|
if plugin_type is None:
|
|
ret = self._base_defs
|
|
elif name is None:
|
|
ret = self._plugins.get(plugin_type, {})
|
|
else:
|
|
ret = self._plugins.get(plugin_type, {}).get(name, {})
|
|
|
|
return ret
|
|
|
|
def _loop_entries(self, container, entry_list):
|
|
''' repeat code for value entry assignment '''
|
|
|
|
value = None
|
|
origin = None
|
|
for entry in entry_list:
|
|
name = entry.get('name')
|
|
temp_value = container.get(name, None)
|
|
if temp_value is not None: # only set if env var is defined
|
|
value = temp_value
|
|
origin = name
|
|
|
|
# deal with deprecation of setting source, if used
|
|
if 'deprecated' in entry:
|
|
self.DEPRECATED.append((entry['name'], entry['deprecated']))
|
|
|
|
return value, origin
|
|
|
|
def get_config_value(self, config, cfile=None, plugin_type=None, plugin_name=None, keys=None, variables=None, direct=None):
|
|
''' wrapper '''
|
|
|
|
try:
|
|
value, _drop = self.get_config_value_and_origin(config, cfile=cfile, plugin_type=plugin_type, plugin_name=plugin_name,
|
|
keys=keys, variables=variables, direct=direct)
|
|
except AnsibleError:
|
|
raise
|
|
except Exception as e:
|
|
raise AnsibleError("Unhandled exception when retrieving %s:\n%s" % (config, to_native(e)), orig_exc=e)
|
|
return value
|
|
|
|
def get_config_value_and_origin(self, config, cfile=None, plugin_type=None, plugin_name=None, keys=None, variables=None, direct=None):
|
|
''' Given a config key figure out the actual value and report on the origin of the settings '''
|
|
if cfile is None:
|
|
# use default config
|
|
cfile = self._config_file
|
|
|
|
# Note: sources that are lists listed in low to high precedence (last one wins)
|
|
value = None
|
|
origin = None
|
|
|
|
defs = self.get_configuration_definitions(plugin_type, plugin_name)
|
|
if config in defs:
|
|
|
|
# direct setting via plugin arguments, can set to None so we bypass rest of processing/defaults
|
|
direct_aliases = []
|
|
if direct:
|
|
direct_aliases = [direct[alias] for alias in defs[config].get('aliases', []) if alias in direct]
|
|
if direct and config in direct:
|
|
value = direct[config]
|
|
origin = 'Direct'
|
|
elif direct and direct_aliases:
|
|
value = direct_aliases[0]
|
|
origin = 'Direct'
|
|
|
|
else:
|
|
# Use 'variable overrides' if present, highest precedence, but only present when querying running play
|
|
if variables and defs[config].get('vars'):
|
|
value, origin = self._loop_entries(variables, defs[config]['vars'])
|
|
origin = 'var: %s' % origin
|
|
|
|
# use playbook keywords if you have em
|
|
if value is None and keys and config in keys:
|
|
value, origin = keys[config], 'keyword'
|
|
origin = 'keyword: %s' % origin
|
|
|
|
# env vars are next precedence
|
|
if value is None and defs[config].get('env'):
|
|
value, origin = self._loop_entries(py3compat.environ, defs[config]['env'])
|
|
origin = 'env: %s' % origin
|
|
|
|
# try config file entries next, if we have one
|
|
if self._parsers.get(cfile, None) is None:
|
|
self._parse_config_file(cfile)
|
|
|
|
if value is None and cfile is not None:
|
|
ftype = get_config_type(cfile)
|
|
if ftype and defs[config].get(ftype):
|
|
if ftype == 'ini':
|
|
# load from ini config
|
|
try: # FIXME: generalize _loop_entries to allow for files also, most of this code is dupe
|
|
for ini_entry in defs[config]['ini']:
|
|
temp_value = get_ini_config_value(self._parsers[cfile], ini_entry)
|
|
if temp_value is not None:
|
|
value = temp_value
|
|
origin = cfile
|
|
if 'deprecated' in ini_entry:
|
|
self.DEPRECATED.append(('[%s]%s' % (ini_entry['section'], ini_entry['key']), ini_entry['deprecated']))
|
|
except Exception as e:
|
|
sys.stderr.write("Error while loading ini config %s: %s" % (cfile, to_native(e)))
|
|
elif ftype == 'yaml':
|
|
# FIXME: implement, also , break down key from defs (. notation???)
|
|
origin = cfile
|
|
|
|
# set default if we got here w/o a value
|
|
if value is None:
|
|
if defs[config].get('required', False):
|
|
if not plugin_type or config not in INTERNAL_DEFS.get(plugin_type, {}):
|
|
raise AnsibleError("No setting was provided for required configuration %s" %
|
|
to_native(_get_entry(plugin_type, plugin_name, config)))
|
|
else:
|
|
value = defs[config].get('default')
|
|
origin = 'default'
|
|
# skip typing as this is a templated default that will be resolved later in constants, which has needed vars
|
|
if plugin_type is None and isinstance(value, string_types) and (value.startswith('{{') and value.endswith('}}')):
|
|
return value, origin
|
|
|
|
# ensure correct type, can raise exceptions on mismatched types
|
|
try:
|
|
value = ensure_type(value, defs[config].get('type'), origin=origin)
|
|
except ValueError as e:
|
|
if origin.startswith('env:') and value == '':
|
|
# this is empty env var for non string so we can set to default
|
|
origin = 'default'
|
|
value = ensure_type(defs[config].get('default'), defs[config].get('type'), origin=origin)
|
|
else:
|
|
raise AnsibleOptionsError('Invalid type for configuration option %s: %s' %
|
|
(to_native(_get_entry(plugin_type, plugin_name, config)), to_native(e)))
|
|
|
|
# deal with deprecation of the setting
|
|
if 'deprecated' in defs[config] and origin != 'default':
|
|
self.DEPRECATED.append((config, defs[config].get('deprecated')))
|
|
else:
|
|
raise AnsibleError('Requested entry (%s) was not defined in configuration.' % to_native(_get_entry(plugin_type, plugin_name, config)))
|
|
|
|
return value, origin
|
|
|
|
def initialize_plugin_configuration_definitions(self, plugin_type, name, defs):
|
|
|
|
if plugin_type not in self._plugins:
|
|
self._plugins[plugin_type] = {}
|
|
|
|
self._plugins[plugin_type][name] = defs
|
|
|
|
def update_module_defaults_groups(self):
|
|
defaults_config = self._read_config_yaml_file(
|
|
'%s/module_defaults.yml' % os.path.join(os.path.dirname(__file__))
|
|
)
|
|
if defaults_config.get('version') not in ('1', '1.0', 1, 1.0):
|
|
raise AnsibleError('module_defaults.yml has an invalid version "%s" for configuration. Could be a bad install.' % defaults_config.get('version'))
|
|
self.module_defaults_groups = defaults_config.get('groupings', {})
|
|
|
|
def update_config_data(self, defs=None, configfile=None):
|
|
''' really: update constants '''
|
|
|
|
if defs is None:
|
|
defs = self._base_defs
|
|
|
|
if configfile is None:
|
|
configfile = self._config_file
|
|
|
|
if not isinstance(defs, dict):
|
|
raise AnsibleOptionsError("Invalid configuration definition type: %s for %s" % (type(defs), defs))
|
|
|
|
# update the constant for config file
|
|
self.data.update_setting(Setting('CONFIG_FILE', configfile, '', 'string'))
|
|
|
|
origin = None
|
|
# env and config defs can have several entries, ordered in list from lowest to highest precedence
|
|
for config in defs:
|
|
if not isinstance(defs[config], dict):
|
|
raise AnsibleOptionsError("Invalid configuration definition '%s': type is %s" % (to_native(config), type(defs[config])))
|
|
|
|
# get value and origin
|
|
try:
|
|
value, origin = self.get_config_value_and_origin(config, configfile)
|
|
except Exception as e:
|
|
# Printing the problem here because, in the current code:
|
|
# (1) we can't reach the error handler for AnsibleError before we
|
|
# hit a different error due to lack of working config.
|
|
# (2) We don't have access to display yet because display depends on config
|
|
# being properly loaded.
|
|
#
|
|
# If we start getting double errors printed from this section of code, then the
|
|
# above problem #1 has been fixed. Revamp this to be more like the try: except
|
|
# in get_config_value() at that time.
|
|
sys.stderr.write("Unhandled error:\n %s\n\n" % traceback.format_exc())
|
|
raise AnsibleError("Invalid settings supplied for %s: %s\n" % (config, to_native(e)), orig_exc=e)
|
|
|
|
# set the constant
|
|
self.data.update_setting(Setting(config, value, origin, defs[config].get('type', 'string')))
|