Change: Completely refactor inventory plugin

This commit is contained in:
Robin Pierre Cordier 2022-05-04 19:51:00 -04:00
parent 0c89469c1e
commit e7c6330020
2 changed files with 412 additions and 281 deletions

View File

@ -8,6 +8,9 @@
from __future__ import (absolute_import, division, print_function) from __future__ import (absolute_import, division, print_function)
# __metaclass__ = type # __metaclass__ = type
from pyinstrument import Profiler
profiler = Profiler()
from ansible import constants as C from ansible import constants as C
from ansible.errors import AnsibleError from ansible.errors import AnsibleError
from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable, Constructable from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable, Constructable
@ -79,6 +82,9 @@ class InventoryModule(BaseInventoryPlugin, Cacheable, Constructable):
config_data = self._read_config_data(path) config_data = self._read_config_data(path)
self._consume_options(config_data) self._consume_options(config_data)
#profiler.start()
# Get options from inventory # Get options from inventory
self.jinja2_native = self.get_option('jinja2_native') self.jinja2_native = self.get_option('jinja2_native')
self.strict = self.get_option('strict') self.strict = self.get_option('strict')
@ -88,15 +94,8 @@ class InventoryModule(BaseInventoryPlugin, Cacheable, Constructable):
self.keyed_groups = self.get_option('keyed_groups') self.keyed_groups = self.get_option('keyed_groups')
# Prepare Kheops instance # Prepare Kheops instance
self.config_file = self.get_option('config')
ansible_config = {
"instance_namespace": self.get_option('instance_namespace'),
"instance_log_level": self.get_option('instance_log_level'),
"instance_explain": self.get_option('instance_explain'),
}
configs = [ configs = [
ansible_config, self.get_option('config'),
self.config_file,
path, path,
] ]
self.kheops = AnsibleKheops(configs=configs, display=self.display) self.kheops = AnsibleKheops(configs=configs, display=self.display)
@ -109,21 +108,29 @@ class InventoryModule(BaseInventoryPlugin, Cacheable, Constructable):
self.display.error(f"Got errors while processing Kheops lookup for host: %s, %s" % (host_name, err)) self.display.error(f"Got errors while processing Kheops lookup for host: %s, %s" % (host_name, err))
raise err raise err
#profiler.stop()
#profiler.print()
#profiler.open_in_browser()
def _populate_host(self, host_name): def _populate_host(self, host_name):
host = self.inventory.get_host(host_name) host = self.inventory.get_host(host_name)
try: try:
ret = self.kheops.super_lookup( ret = self.kheops.super_lookup_seq(
keys=None, keys=None,
scope=None, scope_vars=host.get_vars(),
_templar=self.templar, _templar=self.templar,
_variables=host.get_vars(),
jinja2_native=self.jinja2_native, jinja2_native=self.jinja2_native,
) )
# Aggregate results
aggregated = {}
for res in ret:
aggregated.update(dict(res))
ret = aggregated
except AnsibleError as err: except AnsibleError as err:
self.display.error (f"Could lookup Kheops data for host: {host_name}") self.display.error (f"Could lookup Kheops data for host: {host_name}, {err}")
raise err raise err
# Inject variables into host # Inject variables into host

View File

@ -7,6 +7,7 @@ from typing import Any, Union
from copy import deepcopy from copy import deepcopy
import yaml import yaml
from diskcache import Cache
from ansible.errors import AnsibleError, AnsibleUndefinedVariable from ansible.errors import AnsibleError, AnsibleUndefinedVariable
from ansible.module_utils.common.text.converters import to_native from ansible.module_utils.common.text.converters import to_native
from ansible.utils.display import Display from ansible.utils.display import Display
@ -36,29 +37,75 @@ DOCUMENTATION_OPTION_FRAGMENT = """
env: env:
- name: ANSIBLE_KHEOPS_CONFIG - name: ANSIBLE_KHEOPS_CONFIG
# Query configuration
# ==========================
namespace:
description:
- The Kheops namespace to use
default: 'default'
env:
- name: ANSIBLE_KHEOPS_DEFAULT_NAMESPACE
scope:
description:
- A hash containing the scope to use for the request, the values will be resolved as Ansible facts.
- Use a dot notation to dig deeper into nested hash facts.
default:
node: inventory_hostname
groups: group_names
keys:
description:
- A list of keys to lookup
default: Null
# Behavior configuration
# ==========================
process_scope:
description:
- This setting defines how is parsed the `scope` configuration
- Set `vars` to enable simple variable interpolation
- Set `jinja` to enable jinja string interpolation
default: 'jinja'
choices: ['vars', 'jinja']
process_results:
description:
- This setting defines how is parsed the returned results.
- Set `none` to disable jinja interpolation from result.
- Set `jinja` to enable jinja result interpolation.
- Using jinja may pose some security issues, as you need to be sure that your source of data is properly secured.
default: 'none'
choices: ['none', 'jinja']
jinja2_native:
description:
- Controls whether to use Jinja2 native types.
- It is off by default even if global jinja2_native is True.
- Has no effect if global jinja2_native is False.
- This offers more flexibility than the template module which does not use Jinja2 native types at all.
- Mutually exclusive with the convert_data option.
default: False
type: bool
env:
- name: ANSIBLE_JINJA2_NATIVE
notes:
- Kheops documentation is available on http://kheops.io/
- You can add more parameters as documented in http://kheops.io/server/api
# Backend configuration (Direct/Client)
# ==========================
mode: mode:
description: description:
- Choose `client` to use a remote Khéops instance - Choose `client` to use a remote Khéops instance (Not implemented yet)
- Choose `instance` to use a Khéops directly - Choose `instance` to use a Khéops directly
default: 'instance' default: 'instance'
choices: ['instance', 'client'] choices: ['instance']
env: env:
- name: ANSIBLE_KHEOPS_MODE - name: ANSIBLE_KHEOPS_MODE
# default_namespace:
# description:
# - The Kheops namespace to use
# default: 'default'
# env:
# - name: ANSIBLE_KHEOPS_DEFAULT_NAMESPACE
# default_scope:
# description:
# - A list of default variables to inject in scope.
# default: 'default'
# env:
# - name: ANSIBLE_KHEOPS_DEFAULT_SCOPE
# Instance configuration (Direct) # Instance configuration (Direct)
# ========================== # ==========================
@ -131,63 +178,6 @@ DOCUMENTATION_OPTION_FRAGMENT = """
# turfu env: # turfu env:
# turfu - name: ANSIBLE_KHEOPS_VALIDATE_CERTS # turfu - name: ANSIBLE_KHEOPS_VALIDATE_CERTS
# Query configuration
# ==========================
namespace:
description:
- The Kheops namespace to use
default: 'default'
env:
- name: ANSIBLE_KHEOPS_DEFAULT_NAMESPACE
scope:
description:
- A hash containing the scope to use for the request, the values will be resolved as Ansible facts.
- Use a dot notation to dig deeper into nested hash facts.
default:
node: inventory_hostname
groups: group_names
keys:
description:
- A list of keys to lookup
default: Null
# Behavior configuration
# ==========================
process_scope:
description:
- This setting defines how is parsed the `scope` configuration
- Set `vars` to enable simple variable interpolation
- Set `jinja` to enable jinja string interpolation
default: 'jinja'
choices: ['vars', 'jinja']
process_results:
description:
- This setting defines how is parsed the returned results.
- Set `none` to disable jinja interpolation from result.
- Set `jinja` to enable jinja result interpolation.
- Using jinja may pose some security issues, as you need to be sure that your source of data is properly secured.
default: 'none'
choices: ['none', 'jinja']
jinja2_native:
description:
- Controls whether to use Jinja2 native types.
- It is off by default even if global jinja2_native is True.
- Has no effect if global jinja2_native is False.
- This offers more flexibility than the template module which does not use Jinja2 native types at all.
- Mutually exclusive with the convert_data option.
default: False
type: bool
env:
- name: ANSIBLE_JINJA2_NATIVE
notes:
- Kheops documentation is available on http://kheops.io/
- You can add more parameters as documented in http://kheops.io/server/api
""" """
@ -195,72 +185,230 @@ KEY_NS_SEP = "/"
if USE_JINJA2_NATIVE: if USE_JINJA2_NATIVE:
from ansible.utils.native_jinja import NativeJinjaText from ansible.utils.native_jinja import NativeJinjaText
KHEOPS_CACHE=Cache("/tmp/ansible_kheops_cache/")
KHEOPS_CACHE.clear()
class Key():
"""Key instance class"""
default_config = {
"key": None,
"remap": None,
"scope": {},
"namespace": None,
"namespace_prefix": False,
"explain": False,
"trace": False,
}
def __init__(self, config):
self._config = self.parse_config(config)
# Create all attributes
for key, value in self._config.items():
setattr(self, key, value)
def parse_config(self, key_def):
"""
Parse a key configuration and return an object
A key configuration can either be:
- a string: for quick queries
- a dict: for full control, that can be extended later for more options
String configuration:
- <KEY>
- <NAMESPACE>:<KEY>
- <NAMESPACE>:<KEY>:<REMAP>
Dict configuration:
- key: key to query
- namespace: namespace to query
- remap: rename key to ansible variable
"""
ret = dict(self.default_config)
if isinstance(key_def, dict):
ret.update(key_def)
elif isinstance(key_def, str):
key = None
remap = key
namespace = self.default_namespace
# Extract config from string
parts = key_def.split(KEY_NS_SEP, 3)
if len(parts) == 1:
key = parts[0]
elif len(parts) > 1:
namespace = parts[0]
key = parts[1]
if len(parts) == 3:
remap = parts[2]
# Generate new config
string_conf = {
"key": key,
"namespace": namespace,
"remap": remap,
}
ret.update(string_conf)
else:
raise Exception(f"Key configuration is invalid, expected a string or dict, got: {key_def}")
return ret
@dataclass
class Key:
key: str
remap: Union[str, type(None)]
namespace: Union[type(None), str]
def show(self): def show(self):
"""Method to show the accepatable string format for Kheops"""
ret = self.key ret = self.key
if self.namespace is not None: if self.namespace is not None:
ret = f"{self.namespace}{KEY_NS_SEP}{ret}" ret = f"{self.namespace}{KEY_NS_SEP}{ret}"
return ret return ret
class Keys():
"""Keys config instance class"""
def __init__(self, config, default_namespace="default"):
self.raw_config = config
self.default_namespace = default_namespace
self.key_list = self.parse_config(config)
def parse_config(self, keys_def, default_namespace="default"):
"""Parse a key config structure
A keydef can either be:
- a string: Single lookup
- a list: ordered lookup
- a dict: unordered lookup
"""
ret = []
if isinstance(keys_def, list):
for key_def in keys_def:
ret.append(Key(key_def))
elif isinstance(keys_def, str):
ret.append(Key(keys_def))
elif isinstance(keys_def, dict):
for key, value in keys_def.items():
if isinstance(value, str):
key_def = value or key
if isinstance(value, dict):
key_def = value or {}
key_def['key'] = key_def.get('key', key)
ret.append(Key(key_def))
else:
raise AnsibleError(f"Unable to process Kheops keys: {ret}")
if len(ret) == 0:
raise AnsibleError(f"Kheops query needs at least one key to lookup")
return ret
class AnsibleKheops: class AnsibleKheops:
"""Main Ansible Kheops Class""" """Main Ansible Kheops Class"""
def __init__(self, configs=None, display=None): # Extract default config from documentation
default_config = {
key: value.get("default", None)
for key, value in yaml.safe_load(
DOCUMENTATION_OPTION_FRAGMENT).items()
}
self.configs = configs or [] # Kheops instance management
# ----------------------------
def __init__(self, configs=None, display=None):
self.display = display or Display() self.display = display or Display()
config = self.get_config() configs = configs or []
self.config = self.parse_configs(configs)
self.display.v("Kheops instance has been created, with config: %s" % self.config['instance_config'])
# Instanciate Kheops # Instanciate Kheops
if config["mode"] == "instance": if self.config["mode"] == "instance":
# Configure logging # Configure logging
logger = logging.getLogger("kheops") logger = logging.getLogger("kheops")
logger.setLevel(config["instance_log_level"]) logger.setLevel(self.config["instance_log_level"])
# See for logging: https://medium.com/opsops/debugging-requests-1989797736cc # See for logging: https://medium.com/opsops/debugging-requests-1989797736cc
class ListLoggerHandler(logging.Handler): #class ListLoggerHandler(logging.Handler):
def emit(self, record): # def emit(self, record):
msg = self.format(record) # msg = self.format(record)
main_logger = logging.getLogger() main_logger = logging.getLogger()
main_logger.addHandler(ListLoggerHandler()) #main_logger.addHandler(ListLoggerHandler())
main_logger.setLevel(logging.DEBUG) main_logger.setLevel(logging.DEBUG)
# Start instance # Start instance
self.kheops = Kheops( self.kheops = Kheops(
config=config["instance_config"], namespace=config["instance_namespace"] config=self.config["instance_config"],
namespace=self.config["instance_namespace"],
cache=KHEOPS_CACHE
) )
elif config["mode"] == "client": elif self.config["mode"] == "client":
raise AnsibleError("Kheops client mode is not implemented") raise AnsibleError("Kheops client mode is not implemented")
self.config = config
self.display.v("Kheops instance has been created, with config: %s" % config['instance_config'])
def get_config(self): def parse_configs(self, configs):
""" """
Processing order: Take a list of config items that will be merged together.
- Fetch the value of config or fallback on ANSIBLE_KHEOPS_CONFIG
- Load the config if any A config item can either be:
- Overrides with other options - A string: Represents the path of the config
- A dict: Represent a direct configuration
- None: Just ignore this entry
#Processing order:
#- Fetch the value of config or fallback on ANSIBLE_KHEOPS_CONFIG
#- Load the config if any
#- Overrides with other options
""" """
# Extract default value from doc
data_doc = yaml.safe_load(DOCUMENTATION_OPTION_FRAGMENT)
default_config = {
key: value.get("default", None) for key, value in data_doc.items()
}
# Get default configuration from environment
env_items = [
# We exclude 'config' on purpose
"mode",
"default_namespace",
"instance_config",
"instance_namespace",
"instance_log_level",
"instance_log_explain",
# Future:
# "token",
# "host",
# "port",
# "protocol",
# "validate_certs",
]
env_config = {}
for item in env_items:
envvar = "ANSIBLE_KHEOPS_" + item.upper()
try:
env_config[item] = os.environ[envvar]
except KeyError:
pass
# Merge/process runtime configurations
merged_configs = {} merged_configs = {}
for config in self.configs: for config in configs:
conf_data = None conf_data = None
if isinstance(config, str): if isinstance(config, str):
@ -277,200 +425,92 @@ class AnsibleKheops:
elif isinstance(config, type(None)): elif isinstance(config, type(None)):
continue continue
else: else:
assert False, f"Bad config for: {config}" assert False, f"Bad config for, expected a path or a dict, got type {type(config)}: {config}"
assert isinstance(conf_data, dict), f"Bug with conf_data: {conf_data}" assert isinstance(conf_data, dict), f"Bug with conf_data: {conf_data}"
if isinstance(conf_data, dict): if isinstance(conf_data, dict):
merged_configs.update(conf_data) merged_configs.update(conf_data)
# Get environment config
items = [
# We exclude 'config'
"mode",
"instance_config",
"instance_namespace",
"instance_log_level",
"namespace",
"scope",
"keys",
]
env_config = {}
for item in items:
envvar = "ANSIBLE_KHEOPS_" + item.upper()
try:
env_config[item] = os.environ[envvar]
except KeyError:
pass
# Merge results # Merge results
combined_config = {} combined_config = dict(self.default_config)
combined_config.update(default_config)
combined_config.update(env_config) combined_config.update(env_config)
combined_config.update(merged_configs) combined_config.update(merged_configs)
return combined_config return combined_config
@staticmethod
def parse_string(item, default_namespace):
key = None
remap = key
namespace = default_namespace
if isinstance(item, str): # Lookup methods
# ----------------------------
parts = item.split(KEY_NS_SEP, 3)
if len(parts) > 0:
key = parts[0]
if len(parts) > 1:
namespace = parts[0]
key = parts[1]
if len(parts) > 2:
remap = parts[2]
elif isinstance(item, dict): def super_lookup_seq(
key = item.get("key")
remap = item.get("remap", key)
namespace = item.get("namespace", namespace)
return Key(key=key, remap=remap, namespace=namespace)
@classmethod
def parse_keys(self, data, namespace):
keys = []
if isinstance(data, str):
keys.append(self.parse_string(data, namespace))
elif isinstance(data, list):
for item in data:
keys.append(self.parse_string(item, namespace))
elif isinstance(data, dict):
for key, value in data.items():
item = key
if value:
assert isinstance(value, str), f"Need a string, got: {value}"
item = f"{key}{KEY_NS_SEP}{value}"
keys.append(self.parse_string(item, namespace))
else:
raise AnsibleError(f"Unable to process Kheops keys: {keys}")
return keys
def get_scope_from_host_inventory(self, host_vars, scope=None):
"""
Build scope from host vars
"""
scope = scope or self.config["scope"]
ret = {}
for key, val in scope.items():
# Tofix should this fail silently ?
ret[key] = host_vars.get(val, None)
return ret
def get_scope_from_jinja(self, host_vars, templar, scope=None, jinja2_native=False):
"""
Parse in jinja a dict scope
"""
scope = scope or self.config["scope"]
if USE_JINJA2_NATIVE and not jinja2_native:
_templar = templar.copy_with_new_env(environment_class=AnsibleEnvironment)
else:
_templar = templar
_vars = deepcopy(host_vars)
ret = {}
with _templar.set_temporary_context(available_variables=_vars):
for key, value in scope.items():
res = value
try:
res = _templar.template(
value,
preserve_trailing_newlines=True,
convert_data=True,
escape_backslashes=False,
)
if USE_JINJA2_NATIVE and not jinja2_native:
# jinja2_native is true globally but off for the lookup, we need this text
# not to be processed by literal_eval anywhere in Ansible
res = NativeJinjaText(res)
self.display.vvv(f"Transformed scope value: {value} => {res}")
except AnsibleUndefinedVariable as err:
self.display.error(f"Got templating error for string '{value}': {err}")
raise err
ret[key] = res
return ret
def lookup(self, keys, namespace=None, scope=None, explain=None):
"""
Start a lookup query
"""
if explain is None:
explain = self.config["instance_explain"]
namespace = namespace or self.config["namespace"]
scope = scope or self.config["scope"]
keys = keys or self.config["keys"]
keys_config = self.parse_keys(keys, namespace)
keys = [i.show() for i in keys_config]
self.display.v(f"Kheops keys: {keys}")
self.display.vv(f"Kheops scope: {scope}")
ret = self.kheops.lookup(
keys=keys,
scope=scope,
# trace=True,
explain=explain,
namespace_prefix=False
)
# Remap output
for key in keys_config:
if key.remap is not None and key.remap != key.key:
ret[key.remap] = ret[key.key]
del ret[key.key]
return ret or {}
def super_lookup(
self, self,
keys, keys,
scope_config=None,
scope_vars=None,
namespace=None, namespace=None,
scope=None,
kwargs=None, kwargs=None,
_templar=None, _templar=None,
_variables=None,
_process_scope=None, _process_scope=None,
_process_results=None, _process_results=None,
jinja2_native=False, jinja2_native=False,
): ):
""" """
Lookup method wrapper Sequential Lookup method wrapper
""" """
_process_scope = _process_scope or self.config["process_scope"] # Determine runtime options
_process_results = _process_results or self.config["process_results"] _process_scope = _process_scope if _process_scope in ["vars", "jinja"] else self.config["process_scope"]
_process_results = _process_results if isinstance(_process_results, bool ) else self.config["process_results"]
_reinject_results = True
scope = scope or self.config["scope"] # Build query context
if _process_scope == "vars": namespace = namespace or self.config.get('default_namespace')
scope = self.get_scope_from_host_inventory(_variables, scope=scope) scope_config = scope_config or self.config.get('scope')
elif _process_scope == "jinja":
assert _templar, f"BUG: We expected a templar object here, got: {_templar}" keys = keys or self.config.get('keys', [])
scope = self.get_scope_from_jinja( keys_config = Keys(keys, default_namespace=namespace)
_variables, _templar, scope=scope, jinja2_native=jinja2_native
scope_final = self.get_scope(
_process_scope,
scope_vars, scope_config,
_templar=_templar, jinja2_native=jinja2_native
) )
ret = self.lookup(keys, namespace=namespace, scope=scope) # Query kheops
result = []
ret = {}
for key in keys_config.key_list:
if _reinject_results:
scope_vars.update(dict(ret))
scope_final = self.get_scope(
_process_scope,
scope_vars, scope_config,
_templar=_templar, jinja2_native=jinja2_native
)
# Query kheops
ret = self.kheops.lookup(
keys=[key.show()],
scope=scope_final,
trace=key.trace,
explain=key.explain,
namespace_prefix=key.namespace_prefix
)
# Remap output
if key.remap is not None and key.remap != key.key:
#print (f"REMAP KEY: {key.key} => {key.remap}")
ret[key.remap] = ret[key.key]
del ret[key.key]
# Process output
if _process_results == "jinja": if _process_results == "jinja":
with _templar.set_temporary_context(available_variables=_variables): with _templar.set_temporary_context(available_variables=scope_vars):
ret = _templar.template( ret = _templar.template(
ret, ret,
preserve_trailing_newlines=True, preserve_trailing_newlines=True,
@ -480,4 +520,88 @@ class AnsibleKheops:
if USE_JINJA2_NATIVE and not jinja2_native: if USE_JINJA2_NATIVE and not jinja2_native:
ret = NativeJinjaText(ret) ret = NativeJinjaText(ret)
result.append(ret)
return result
# Scope management methods
# ----------------------------
def get_scope(self, method, scope_vars, scope_config, _templar=None, jinja2_native=False):
"""
Simple wrapper for _get_scope_template and _get_scope_vars
"""
ret = {}
if method == "jinja":
ret = self._get_scope_template(scope_vars, scope_config,
_templar, jinja2_native=jinja2_native)
elif method == "vars":
ret = self._get_scope_vars(scope_vars, scope_config)
elif method == "all":
ret = scope_vars
else:
raise AnsibleError(f"Get_scope only accept 'jinja','vars' or 'all', got: {method}")
#print ("===> Scope Config:", method)
#print (scope_config)
#print ("===> Scope Vars:", scope_vars.get('tiger_profiles', 'MISSING_PROFILE'))
#print (scope_vars.keys())
#print ("===> Scope Result:")
#print (ret)
#print ("===> Scope EOF")
return dict(ret)
def _get_scope_template(self, data, scope_config, templar, jinja2_native=False):
"""
Create a scope context from data, jinja2 interpolation
"""
scope_config = scope_config or self.config.get('scope', {})
assert isinstance(data, dict)
assert templar
if USE_JINJA2_NATIVE and not jinja2_native:
_templar = templar.copy_with_new_env(environment_class=AnsibleEnvironment)
else:
_templar = templar
# Process template with jinja
_vars = dict(data)
ret = {}
with _templar.set_temporary_context(available_variables=_vars):
try:
ret = _templar.template(
scope_config,
preserve_trailing_newlines=True,
convert_data=True,
escape_backslashes=False,
)
if USE_JINJA2_NATIVE and not jinja2_native:
# jinja2_native is true globally but off for the lookup, we need this text
# not to be processed by literal_eval anywhere in Ansible
ret = NativeJinjaText(ret)
self.display.vvv(f"Transformed scope value: {scope_config} => {ret}")
except AnsibleUndefinedVariable as err:
self.display.error(f"Got templating error for string '{scope_config}': {err}")
raise err
return ret return ret
def _get_scope_vars(self, data, scope_config):
"""
Create a scope context from data, simple interpolation
"""
scope_config = scope_config or self.config.get('scope', {})
assert isinstance(data, dict)
ret = {}
for key, val in scope_config.items():
ret[key] = data.get(val, None)
return ret