diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 12:04:41 +0000 |
commit | 975f66f2eebe9dadba04f275774d4ab83f74cf25 (patch) | |
tree | 89bd26a93aaae6a25749145b7e4bca4a1e75b2be /ansible_collections/community/general/plugins/inventory | |
parent | Initial commit. (diff) | |
download | ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.tar.xz ansible-975f66f2eebe9dadba04f275774d4ab83f74cf25.zip |
Adding upstream version 7.7.0+dfsg.upstream/7.7.0+dfsg
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/community/general/plugins/inventory')
13 files changed, 4850 insertions, 0 deletions
diff --git a/ansible_collections/community/general/plugins/inventory/cobbler.py b/ansible_collections/community/general/plugins/inventory/cobbler.py new file mode 100644 index 000000000..936a409ae --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/cobbler.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2020 Orion Poplawski <orion@nwra.com> +# Copyright (c) 2020 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + author: Orion Poplawski (@opoplawski) + name: cobbler + short_description: Cobbler inventory source + version_added: 1.0.0 + description: + - Get inventory hosts from the cobbler service. + - "Uses a configuration file as an inventory source, it must end in C(.cobbler.yml) or C(.cobbler.yaml) and has a C(plugin: cobbler) entry." + extends_documentation_fragment: + - inventory_cache + options: + plugin: + description: The name of this plugin, it should always be set to C(community.general.cobbler) for this plugin to recognize it as it's own. + required: true + choices: [ 'cobbler', 'community.general.cobbler' ] + url: + description: URL to cobbler. + default: 'http://cobbler/cobbler_api' + env: + - name: COBBLER_SERVER + user: + description: Cobbler authentication user. + required: false + env: + - name: COBBLER_USER + password: + description: Cobbler authentication password + required: false + env: + - name: COBBLER_PASSWORD + cache_fallback: + description: Fallback to cached results if connection to cobbler fails + type: boolean + default: false + exclude_profiles: + description: + - Profiles to exclude from inventory. + - Ignored if I(include_profiles) is specified. + type: list + default: [] + elements: str + include_profiles: + description: + - Profiles to include from inventory. + - If specified, all other profiles will be excluded. + - I(exclude_profiles) is ignored if I(include_profiles) is specified. + type: list + default: [] + elements: str + version_added: 4.4.0 + group_by: + description: Keys to group hosts by + type: list + elements: string + default: [ 'mgmt_classes', 'owners', 'status' ] + group: + description: Group to place all hosts into + default: cobbler + group_prefix: + description: Prefix to apply to cobbler groups + default: cobbler_ + want_facts: + description: Toggle, if C(true) the plugin will retrieve host facts from the server + type: boolean + default: true +''' + +EXAMPLES = ''' +# my.cobbler.yml +plugin: community.general.cobbler +url: http://cobbler/cobbler_api +user: ansible-tester +password: secure +''' + +import socket + +from ansible.errors import AnsibleError +from ansible.module_utils.common.text.converters import to_text +from ansible.module_utils.six import iteritems +from ansible.plugins.inventory import BaseInventoryPlugin, Cacheable, to_safe_group_name + +# xmlrpc +try: + import xmlrpclib as xmlrpc_client + HAS_XMLRPC_CLIENT = True +except ImportError: + try: + import xmlrpc.client as xmlrpc_client + HAS_XMLRPC_CLIENT = True + except ImportError: + HAS_XMLRPC_CLIENT = False + + +class InventoryModule(BaseInventoryPlugin, Cacheable): + ''' Host inventory parser for ansible using cobbler as source. ''' + + NAME = 'community.general.cobbler' + + def __init__(self): + super(InventoryModule, self).__init__() + self.cache_key = None + self.connection = None + + def verify_file(self, path): + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('cobbler.yaml', 'cobbler.yml')): + valid = True + else: + self.display.vvv('Skipping due to inventory source not ending in "cobbler.yaml" nor "cobbler.yml"') + return valid + + def _get_connection(self): + if not HAS_XMLRPC_CLIENT: + raise AnsibleError('Could not import xmlrpc client library') + + if self.connection is None: + self.display.vvvv('Connecting to %s\n' % self.cobbler_url) + self.connection = xmlrpc_client.Server(self.cobbler_url, allow_none=True) + self.token = None + if self.get_option('user') is not None: + self.token = self.connection.login(self.get_option('user'), self.get_option('password')) + return self.connection + + def _init_cache(self): + if self.cache_key not in self._cache: + self._cache[self.cache_key] = {} + + def _reload_cache(self): + if self.get_option('cache_fallback'): + self.display.vvv('Cannot connect to server, loading cache\n') + self._options['cache_timeout'] = 0 + self.load_cache_plugin() + self._cache.get(self.cache_key, {}) + + def _get_profiles(self): + if not self.use_cache or 'profiles' not in self._cache.get(self.cache_key, {}): + c = self._get_connection() + try: + if self.token is not None: + data = c.get_profiles(self.token) + else: + data = c.get_profiles() + except (socket.gaierror, socket.error, xmlrpc_client.ProtocolError): + self._reload_cache() + else: + self._init_cache() + self._cache[self.cache_key]['profiles'] = data + + return self._cache[self.cache_key]['profiles'] + + def _get_systems(self): + if not self.use_cache or 'systems' not in self._cache.get(self.cache_key, {}): + c = self._get_connection() + try: + if self.token is not None: + data = c.get_systems(self.token) + else: + data = c.get_systems() + except (socket.gaierror, socket.error, xmlrpc_client.ProtocolError): + self._reload_cache() + else: + self._init_cache() + self._cache[self.cache_key]['systems'] = data + + return self._cache[self.cache_key]['systems'] + + def _add_safe_group_name(self, group, child=None): + group_name = self.inventory.add_group(to_safe_group_name('%s%s' % (self.get_option('group_prefix'), group.lower().replace(" ", "")))) + if child is not None: + self.inventory.add_child(group_name, child) + return group_name + + def _exclude_profile(self, profile): + if self.include_profiles: + return profile not in self.include_profiles + else: + return profile in self.exclude_profiles + + def parse(self, inventory, loader, path, cache=True): + + super(InventoryModule, self).parse(inventory, loader, path) + + # read config from file, this sets 'options' + self._read_config_data(path) + + # get connection host + self.cobbler_url = self.get_option('url') + self.cache_key = self.get_cache_key(path) + self.use_cache = cache and self.get_option('cache') + + self.exclude_profiles = self.get_option('exclude_profiles') + self.include_profiles = self.get_option('include_profiles') + self.group_by = self.get_option('group_by') + + for profile in self._get_profiles(): + if profile['parent']: + self.display.vvvv('Processing profile %s with parent %s\n' % (profile['name'], profile['parent'])) + if not self._exclude_profile(profile['parent']): + parent_group_name = self._add_safe_group_name(profile['parent']) + self.display.vvvv('Added profile parent group %s\n' % parent_group_name) + if not self._exclude_profile(profile['name']): + group_name = self._add_safe_group_name(profile['name']) + self.display.vvvv('Added profile group %s\n' % group_name) + self.inventory.add_child(parent_group_name, group_name) + else: + self.display.vvvv('Processing profile %s without parent\n' % profile['name']) + # Create a hierarchy of profile names + profile_elements = profile['name'].split('-') + i = 0 + while i < len(profile_elements) - 1: + profile_group = '-'.join(profile_elements[0:i + 1]) + profile_group_child = '-'.join(profile_elements[0:i + 2]) + if self._exclude_profile(profile_group): + self.display.vvvv('Excluding profile %s\n' % profile_group) + break + group_name = self._add_safe_group_name(profile_group) + self.display.vvvv('Added profile group %s\n' % group_name) + child_group_name = self._add_safe_group_name(profile_group_child) + self.display.vvvv('Added profile child group %s to %s\n' % (child_group_name, group_name)) + self.inventory.add_child(group_name, child_group_name) + i = i + 1 + + # Add default group for this inventory if specified + self.group = to_safe_group_name(self.get_option('group')) + if self.group is not None and self.group != '': + self.inventory.add_group(self.group) + self.display.vvvv('Added site group %s\n' % self.group) + + for host in self._get_systems(): + # Get the FQDN for the host and add it to the right groups + hostname = host['hostname'] # None + interfaces = host['interfaces'] + + if self._exclude_profile(host['profile']): + self.display.vvvv('Excluding host %s in profile %s\n' % (host['name'], host['profile'])) + continue + + # hostname is often empty for non-static IP hosts + if hostname == '': + for (iname, ivalue) in iteritems(interfaces): + if ivalue['management'] or not ivalue['static']: + this_dns_name = ivalue.get('dns_name', None) + if this_dns_name is not None and this_dns_name != "": + hostname = this_dns_name + self.display.vvvv('Set hostname to %s from %s\n' % (hostname, iname)) + + if hostname == '': + self.display.vvvv('Cannot determine hostname for host %s, skipping\n' % host['name']) + continue + + self.inventory.add_host(hostname) + self.display.vvvv('Added host %s hostname %s\n' % (host['name'], hostname)) + + # Add host to profile group + group_name = self._add_safe_group_name(host['profile'], child=hostname) + self.display.vvvv('Added host %s to profile group %s\n' % (hostname, group_name)) + + # Add host to groups specified by group_by fields + for group_by in self.group_by: + if host[group_by] == '<<inherit>>': + groups = [] + else: + groups = [host[group_by]] if isinstance(host[group_by], str) else host[group_by] + for group in groups: + group_name = self._add_safe_group_name(group, child=hostname) + self.display.vvvv('Added host %s to group_by %s group %s\n' % (hostname, group_by, group_name)) + + # Add to group for this inventory + if self.group is not None: + self.inventory.add_child(self.group, hostname) + + # Add host variables + if self.get_option('want_facts'): + try: + self.inventory.set_variable(hostname, 'cobbler', host) + except ValueError as e: + self.display.warning("Could not set host info for %s: %s" % (hostname, to_text(e))) diff --git a/ansible_collections/community/general/plugins/inventory/gitlab_runners.py b/ansible_collections/community/general/plugins/inventory/gitlab_runners.py new file mode 100644 index 000000000..d68b8d4e2 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/gitlab_runners.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Stefan Heitmueller <stefan.heitmueller@gmx.com> +# Copyright (c) 2018 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +DOCUMENTATION = ''' + name: gitlab_runners + author: + - Stefan Heitmüller (@morph027) <stefan.heitmueller@gmx.com> + short_description: Ansible dynamic inventory plugin for GitLab runners. + requirements: + - python >= 2.7 + - python-gitlab > 1.8.0 + extends_documentation_fragment: + - constructed + description: + - Reads inventories from the GitLab API. + - Uses a YAML configuration file gitlab_runners.[yml|yaml]. + options: + plugin: + description: The name of this plugin, it should always be set to 'gitlab_runners' for this plugin to recognize it as it's own. + type: str + required: true + choices: + - gitlab_runners + - community.general.gitlab_runners + server_url: + description: The URL of the GitLab server, with protocol (i.e. http or https). + env: + - name: GITLAB_SERVER_URL + version_added: 1.0.0 + type: str + required: true + api_token: + description: GitLab token for logging in. + env: + - name: GITLAB_API_TOKEN + version_added: 1.0.0 + type: str + aliases: + - private_token + - access_token + filter: + description: filter runners from GitLab API + env: + - name: GITLAB_FILTER + version_added: 1.0.0 + type: str + choices: ['active', 'paused', 'online', 'specific', 'shared'] + verbose_output: + description: Toggle to (not) include all available nodes metadata + type: bool + default: true +''' + +EXAMPLES = ''' +# gitlab_runners.yml +plugin: community.general.gitlab_runners +host: https://gitlab.com + +# Example using constructed features to create groups and set ansible_host +plugin: community.general.gitlab_runners +host: https://gitlab.com +strict: false +keyed_groups: + # add e.g. amd64 hosts to an arch_amd64 group + - prefix: arch + key: 'architecture' + # add e.g. linux hosts to an os_linux group + - prefix: os + key: 'platform' + # create a group per runner tag + # e.g. a runner tagged w/ "production" ends up in group "label_production" + # hint: labels containing special characters will be converted to safe names + - key: 'tag_list' + prefix: tag +''' + +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.module_utils.common.text.converters import to_native +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable + +try: + import gitlab + HAS_GITLAB = True +except ImportError: + HAS_GITLAB = False + + +class InventoryModule(BaseInventoryPlugin, Constructable): + ''' Host inventory parser for ansible using GitLab API as source. ''' + + NAME = 'community.general.gitlab_runners' + + def _populate(self): + gl = gitlab.Gitlab(self.get_option('server_url'), private_token=self.get_option('api_token')) + self.inventory.add_group('gitlab_runners') + try: + if self.get_option('filter'): + runners = gl.runners.all(scope=self.get_option('filter')) + else: + runners = gl.runners.all() + for runner in runners: + host = str(runner['id']) + ip_address = runner['ip_address'] + host_attrs = vars(gl.runners.get(runner['id']))['_attrs'] + self.inventory.add_host(host, group='gitlab_runners') + self.inventory.set_variable(host, 'ansible_host', ip_address) + if self.get_option('verbose_output', True): + self.inventory.set_variable(host, 'gitlab_runner_attributes', host_attrs) + + # Use constructed if applicable + strict = self.get_option('strict') + # Composed variables + self._set_composite_vars(self.get_option('compose'), host_attrs, host, strict=strict) + # Complex groups based on jinja2 conditionals, hosts that meet the conditional are added to group + self._add_host_to_composed_groups(self.get_option('groups'), host_attrs, host, strict=strict) + # Create groups based on variable values and add the corresponding hosts to it + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), host_attrs, host, strict=strict) + except Exception as e: + raise AnsibleParserError('Unable to fetch hosts from GitLab API, this was the original exception: %s' % to_native(e)) + + def verify_file(self, path): + """Return the possibly of a file being consumable by this plugin.""" + return ( + super(InventoryModule, self).verify_file(path) and + path.endswith(("gitlab_runners.yaml", "gitlab_runners.yml"))) + + def parse(self, inventory, loader, path, cache=True): + if not HAS_GITLAB: + raise AnsibleError('The GitLab runners dynamic inventory plugin requires python-gitlab: https://python-gitlab.readthedocs.io/en/stable/') + super(InventoryModule, self).parse(inventory, loader, path, cache) + self._read_config_data(path) + self._populate() diff --git a/ansible_collections/community/general/plugins/inventory/icinga2.py b/ansible_collections/community/general/plugins/inventory/icinga2.py new file mode 100644 index 000000000..70e0f5733 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/icinga2.py @@ -0,0 +1,294 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Cliff Hults <cliff.hlts@gmail.com> +# Copyright (c) 2021 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +DOCUMENTATION = ''' + name: icinga2 + short_description: Icinga2 inventory source + version_added: 3.7.0 + author: + - Cliff Hults (@BongoEADGC6) <cliff.hults@gmail.com> + description: + - Get inventory hosts from the Icinga2 API. + - "Uses a configuration file as an inventory source, it must end in + C(.icinga2.yml) or C(.icinga2.yaml)." + extends_documentation_fragment: + - constructed + options: + strict: + version_added: 4.4.0 + compose: + version_added: 4.4.0 + groups: + version_added: 4.4.0 + keyed_groups: + version_added: 4.4.0 + plugin: + description: Name of the plugin. + required: true + type: string + choices: ['community.general.icinga2'] + url: + description: Root URL of Icinga2 API. + type: string + required: true + user: + description: Username to query the API. + type: string + required: true + password: + description: Password to query the API. + type: string + required: true + host_filter: + description: + - An Icinga2 API valid host filter. Leave blank for no filtering + type: string + required: false + validate_certs: + description: Enables or disables SSL certificate verification. + type: boolean + default: true + inventory_attr: + description: + - Allows the override of the inventory name based on different attributes. + - This allows for changing the way limits are used. + - The current default, C(address), is sometimes not unique or present. We recommend to use C(name) instead. + type: string + default: address + choices: ['name', 'display_name', 'address'] + version_added: 4.2.0 +''' + +EXAMPLES = r''' +# my.icinga2.yml +plugin: community.general.icinga2 +url: http://localhost:5665 +user: ansible +password: secure +host_filter: \"linux-servers\" in host.groups +validate_certs: false +inventory_attr: name +groups: + # simple name matching + webservers: inventory_hostname.startswith('web') + + # using icinga2 template + databaseservers: "'db-template' in (icinga2_attributes.templates|list)" + +compose: + # set all icinga2 attributes to a host variable 'icinga2_attrs' + icinga2_attrs: icinga2_attributes + + # set 'ansible_user' and 'ansible_port' from icinga2 host vars + ansible_user: icinga2_attributes.vars.ansible_user + ansible_port: icinga2_attributes.vars.ansible_port | default(22) +''' + +import json + +from ansible.errors import AnsibleParserError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +class InventoryModule(BaseInventoryPlugin, Constructable): + ''' Host inventory parser for ansible using Icinga2 as source. ''' + + NAME = 'community.general.icinga2' + + def __init__(self): + + super(InventoryModule, self).__init__() + + # from config + self.icinga2_url = None + self.icinga2_user = None + self.icinga2_password = None + self.ssl_verify = None + self.host_filter = None + self.inventory_attr = None + + self.cache_key = None + self.use_cache = None + + def verify_file(self, path): + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('icinga2.yaml', 'icinga2.yml')): + valid = True + else: + self.display.vvv('Skipping due to inventory source not ending in "icinga2.yaml" nor "icinga2.yml"') + return valid + + def _api_connect(self): + self.headers = { + 'User-Agent': "ansible-icinga2-inv", + 'Accept': "application/json", + } + api_status_url = self.icinga2_url + "/status" + request_args = { + 'headers': self.headers, + 'url_username': self.icinga2_user, + 'url_password': self.icinga2_password, + 'validate_certs': self.ssl_verify + } + open_url(api_status_url, **request_args) + + def _post_request(self, request_url, data=None): + self.display.vvv("Requested URL: %s" % request_url) + request_args = { + 'headers': self.headers, + 'url_username': self.icinga2_user, + 'url_password': self.icinga2_password, + 'validate_certs': self.ssl_verify + } + if data is not None: + request_args['data'] = json.dumps(data) + self.display.vvv("Request Args: %s" % request_args) + try: + response = open_url(request_url, **request_args) + except HTTPError as e: + try: + error_body = json.loads(e.read().decode()) + self.display.vvv("Error returned: {0}".format(error_body)) + except Exception: + error_body = {"status": None} + if e.code == 404 and error_body.get('status') == "No objects found.": + raise AnsibleParserError("Host filter returned no data. Please confirm your host_filter value is valid") + raise AnsibleParserError("Unexpected data returned: {0} -- {1}".format(e, error_body)) + + response_body = response.read() + json_data = json.loads(response_body.decode('utf-8')) + self.display.vvv("Returned Data: %s" % json.dumps(json_data, indent=4, sort_keys=True)) + if 200 <= response.status <= 299: + return json_data + if response.status == 404 and json_data['status'] == "No objects found.": + raise AnsibleParserError( + "API returned no data -- Response: %s - %s" + % (response.status, json_data['status'])) + if response.status == 401: + raise AnsibleParserError( + "API was unable to complete query -- Response: %s - %s" + % (response.status, json_data['status'])) + if response.status == 500: + raise AnsibleParserError( + "API Response - %s - %s" + % (json_data['status'], json_data['errors'])) + raise AnsibleParserError( + "Unexpected data returned - %s - %s" + % (json_data['status'], json_data['errors'])) + + def _query_hosts(self, hosts=None, attrs=None, joins=None, host_filter=None): + query_hosts_url = "{0}/objects/hosts".format(self.icinga2_url) + self.headers['X-HTTP-Method-Override'] = 'GET' + data_dict = dict() + if hosts: + data_dict['hosts'] = hosts + if attrs is not None: + data_dict['attrs'] = attrs + if joins is not None: + data_dict['joins'] = joins + if host_filter is not None: + data_dict['filter'] = host_filter.replace("\\\"", "\"") + self.display.vvv(host_filter) + host_dict = self._post_request(query_hosts_url, data_dict) + return host_dict['results'] + + def get_inventory_from_icinga(self): + """Query for all hosts """ + self.display.vvv("Querying Icinga2 for inventory") + query_args = { + "attrs": ["address", "address6", "name", "display_name", "state_type", "state", "templates", "groups", "vars", "zone"], + } + if self.host_filter is not None: + query_args['host_filter'] = self.host_filter + # Icinga2 API Call + results_json = self._query_hosts(**query_args) + # Manipulate returned API data to Ansible inventory spec + ansible_inv = self._convert_inv(results_json) + return ansible_inv + + def _apply_constructable(self, name, variables): + strict = self.get_option('strict') + self._add_host_to_composed_groups(self.get_option('groups'), variables, name, strict=strict) + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), variables, name, strict=strict) + self._set_composite_vars(self.get_option('compose'), variables, name, strict=strict) + + def _populate(self): + groups = self._to_json(self.get_inventory_from_icinga()) + return groups + + def _to_json(self, in_dict): + """Convert dictionary to JSON""" + return json.dumps(in_dict, sort_keys=True, indent=2) + + def _convert_inv(self, json_data): + """Convert Icinga2 API data to JSON format for Ansible""" + groups_dict = {"_meta": {"hostvars": {}}} + for entry in json_data: + host_attrs = entry['attrs'] + if self.inventory_attr == "name": + host_name = entry.get('name') + if self.inventory_attr == "address": + # When looking for address for inventory, if missing fallback to object name + if host_attrs.get('address', '') != '': + host_name = host_attrs.get('address') + else: + host_name = entry.get('name') + if self.inventory_attr == "display_name": + host_name = host_attrs.get('display_name') + if host_attrs['state'] == 0: + host_attrs['state'] = 'on' + else: + host_attrs['state'] = 'off' + host_groups = host_attrs.get('groups') + self.inventory.add_host(host_name) + for group in host_groups: + if group not in self.inventory.groups.keys(): + self.inventory.add_group(group) + self.inventory.add_child(group, host_name) + # If the address attribute is populated, override ansible_host with the value + if host_attrs.get('address') != '': + self.inventory.set_variable(host_name, 'ansible_host', host_attrs.get('address')) + self.inventory.set_variable(host_name, 'hostname', entry.get('name')) + self.inventory.set_variable(host_name, 'display_name', host_attrs.get('display_name')) + self.inventory.set_variable(host_name, 'state', + host_attrs['state']) + self.inventory.set_variable(host_name, 'state_type', + host_attrs['state_type']) + # Adds all attributes to a variable 'icinga2_attributes' + construct_vars = dict(self.inventory.get_host(host_name).get_vars()) + construct_vars['icinga2_attributes'] = host_attrs + self._apply_constructable(host_name, construct_vars) + return groups_dict + + def parse(self, inventory, loader, path, cache=True): + + super(InventoryModule, self).parse(inventory, loader, path) + + # read config from file, this sets 'options' + self._read_config_data(path) + + # Store the options from the YAML file + self.icinga2_url = self.get_option('url').rstrip('/') + '/v1' + self.icinga2_user = self.get_option('user') + self.icinga2_password = self.get_option('password') + self.ssl_verify = self.get_option('validate_certs') + self.host_filter = self.get_option('host_filter') + self.inventory_attr = self.get_option('inventory_attr') + # Not currently enabled + # self.cache_key = self.get_cache_key(path) + # self.use_cache = cache and self.get_option('cache') + + # Test connection to API + self._api_connect() + + # Call our internal helper to populate the dynamic inventory + self._populate() diff --git a/ansible_collections/community/general/plugins/inventory/linode.py b/ansible_collections/community/general/plugins/inventory/linode.py new file mode 100644 index 000000000..b28cfa27b --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/linode.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' + name: linode + author: + - Luke Murphy (@decentral1se) + short_description: Ansible dynamic inventory plugin for Linode. + requirements: + - python >= 2.7 + - linode_api4 >= 2.0.0 + description: + - Reads inventories from the Linode API v4. + - Uses a YAML configuration file that ends with linode.(yml|yaml). + - Linode labels are used by default as the hostnames. + - The default inventory groups are built from groups (deprecated by + Linode) and not tags. + extends_documentation_fragment: + - constructed + - inventory_cache + options: + cache: + version_added: 4.5.0 + cache_plugin: + version_added: 4.5.0 + cache_timeout: + version_added: 4.5.0 + cache_connection: + version_added: 4.5.0 + cache_prefix: + version_added: 4.5.0 + plugin: + description: Marks this as an instance of the 'linode' plugin. + required: true + choices: ['linode', 'community.general.linode'] + ip_style: + description: Populate hostvars with all information available from the Linode APIv4. + type: string + default: plain + choices: + - plain + - api + version_added: 3.6.0 + access_token: + description: The Linode account personal access token. + required: true + env: + - name: LINODE_ACCESS_TOKEN + regions: + description: Populate inventory with instances in this region. + default: [] + type: list + elements: string + tags: + description: Populate inventory only with instances which have at least one of the tags listed here. + default: [] + type: list + elements: string + version_added: 2.0.0 + types: + description: Populate inventory with instances with this type. + default: [] + type: list + elements: string + strict: + version_added: 2.0.0 + compose: + version_added: 2.0.0 + groups: + version_added: 2.0.0 + keyed_groups: + version_added: 2.0.0 +''' + +EXAMPLES = r''' +# Minimal example. `LINODE_ACCESS_TOKEN` is exposed in environment. +plugin: community.general.linode + +# You can use Jinja to template the access token. +plugin: community.general.linode +access_token: "{{ lookup('ini', 'token', section='your_username', file='~/.config/linode-cli') }}" +# For older Ansible versions, you need to write this as: +# access_token: "{{ lookup('ini', 'token section=your_username file=~/.config/linode-cli') }}" + +# Example with regions, types, groups and access token +plugin: community.general.linode +access_token: foobar +regions: + - eu-west +types: + - g5-standard-2 + +# Example with keyed_groups, groups, and compose +plugin: community.general.linode +access_token: foobar +keyed_groups: + - key: tags + separator: '' + - key: region + prefix: region +groups: + webservers: "'web' in (tags|list)" + mailservers: "'mail' in (tags|list)" +compose: + # By default, Ansible tries to connect to the label of the instance. + # Since that might not be a valid name to connect to, you can + # replace it with the first IPv4 address of the linode as follows: + ansible_ssh_host: ipv4[0] + ansible_port: 2222 + +# Example where control traffic limited to internal network +plugin: community.general.linode +access_token: foobar +ip_style: api +compose: + ansible_host: "ipv4 | community.general.json_query('[?public==`false`].address') | first" +''' + +from ansible.errors import AnsibleError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable + + +try: + from linode_api4 import LinodeClient + from linode_api4.objects.linode import Instance + from linode_api4.errors import ApiError as LinodeApiError + HAS_LINODE = True +except ImportError: + HAS_LINODE = False + + +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): + + NAME = 'community.general.linode' + + def _build_client(self, loader): + """Build the Linode client.""" + + access_token = self.get_option('access_token') + if self.templar.is_template(access_token): + access_token = self.templar.template(variable=access_token, disable_lookups=False) + + if access_token is None: + raise AnsibleError(( + 'Could not retrieve Linode access token ' + 'from plugin configuration sources' + )) + + self.client = LinodeClient(access_token) + + def _get_instances_inventory(self): + """Retrieve Linode instance information from cloud inventory.""" + try: + self.instances = self.client.linode.instances() + except LinodeApiError as exception: + raise AnsibleError('Linode client raised: %s' % exception) + + def _add_groups(self): + """Add Linode instance groups to the dynamic inventory.""" + self.linode_groups = set( + filter(None, [ + instance.group + for instance + in self.instances + ]) + ) + + for linode_group in self.linode_groups: + self.inventory.add_group(linode_group) + + def _filter_by_config(self): + """Filter instances by user specified configuration.""" + regions = self.get_option('regions') + if regions: + self.instances = [ + instance for instance in self.instances + if instance.region.id in regions + ] + + types = self.get_option('types') + if types: + self.instances = [ + instance for instance in self.instances + if instance.type.id in types + ] + + tags = self.get_option('tags') + if tags: + self.instances = [ + instance for instance in self.instances + if any(tag in instance.tags for tag in tags) + ] + + def _add_instances_to_groups(self): + """Add instance names to their dynamic inventory groups.""" + for instance in self.instances: + self.inventory.add_host(instance.label, group=instance.group) + + def _add_hostvars_for_instances(self): + """Add hostvars for instances in the dynamic inventory.""" + ip_style = self.get_option('ip_style') + for instance in self.instances: + hostvars = instance._raw_json + for hostvar_key in hostvars: + if ip_style == 'api' and hostvar_key in ['ipv4', 'ipv6']: + continue + self.inventory.set_variable( + instance.label, + hostvar_key, + hostvars[hostvar_key] + ) + if ip_style == 'api': + ips = instance.ips.ipv4.public + instance.ips.ipv4.private + ips += [instance.ips.ipv6.slaac, instance.ips.ipv6.link_local] + ips += instance.ips.ipv6.pools + + for ip_type in set(ip.type for ip in ips): + self.inventory.set_variable( + instance.label, + ip_type, + self._ip_data([ip for ip in ips if ip.type == ip_type]) + ) + + def _ip_data(self, ip_list): + data = [] + for ip in list(ip_list): + data.append( + { + 'address': ip.address, + 'subnet_mask': ip.subnet_mask, + 'gateway': ip.gateway, + 'public': ip.public, + 'prefix': ip.prefix, + 'rdns': ip.rdns, + 'type': ip.type + } + ) + return data + + def _cacheable_inventory(self): + return [i._raw_json for i in self.instances] + + def populate(self): + strict = self.get_option('strict') + + self._filter_by_config() + + self._add_groups() + self._add_instances_to_groups() + self._add_hostvars_for_instances() + for instance in self.instances: + variables = self.inventory.get_host(instance.label).get_vars() + self._add_host_to_composed_groups( + self.get_option('groups'), + variables, + instance.label, + strict=strict) + self._add_host_to_keyed_groups( + self.get_option('keyed_groups'), + variables, + instance.label, + strict=strict) + self._set_composite_vars( + self.get_option('compose'), + variables, + instance.label, + strict=strict) + + def verify_file(self, path): + """Verify the Linode configuration file.""" + if super(InventoryModule, self).verify_file(path): + endings = ('linode.yaml', 'linode.yml') + if any((path.endswith(ending) for ending in endings)): + return True + return False + + def parse(self, inventory, loader, path, cache=True): + """Dynamically parse Linode the cloud inventory.""" + super(InventoryModule, self).parse(inventory, loader, path) + self.instances = None + + if not HAS_LINODE: + raise AnsibleError('the Linode dynamic inventory plugin requires linode_api4.') + + self._read_config_data(path) + + cache_key = self.get_cache_key(path) + + if cache: + cache = self.get_option('cache') + + update_cache = False + if cache: + try: + self.instances = [Instance(None, i["id"], i) for i in self._cache[cache_key]] + except KeyError: + update_cache = True + + # Check for None rather than False in order to allow + # for empty sets of cached instances + if self.instances is None: + self._build_client(loader) + self._get_instances_inventory() + + if update_cache: + self._cache[cache_key] = self._cacheable_inventory() + + self.populate() diff --git a/ansible_collections/community/general/plugins/inventory/lxd.py b/ansible_collections/community/general/plugins/inventory/lxd.py new file mode 100644 index 000000000..bd0a6ce00 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/lxd.py @@ -0,0 +1,1099 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frank Dornheim <dornheim@posteo.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' + name: lxd + short_description: Returns Ansible inventory from lxd host + description: + - Get inventory from the lxd. + - Uses a YAML configuration file that ends with 'lxd.(yml|yaml)'. + version_added: "3.0.0" + author: "Frank Dornheim (@conloos)" + requirements: + - ipaddress + - lxd >= 4.0 + options: + plugin: + description: Token that ensures this is a source file for the 'lxd' plugin. + required: true + choices: [ 'community.general.lxd' ] + url: + description: + - The unix domain socket path or the https URL for the lxd server. + - Sockets in filesystem have to start with C(unix:). + - Mostly C(unix:/var/lib/lxd/unix.socket) or C(unix:/var/snap/lxd/common/lxd/unix.socket). + default: unix:/var/snap/lxd/common/lxd/unix.socket + type: str + client_key: + description: + - The client certificate key file path. + aliases: [ key_file ] + default: $HOME/.config/lxc/client.key + type: path + client_cert: + description: + - The client certificate file path. + aliases: [ cert_file ] + default: $HOME/.config/lxc/client.crt + type: path + trust_password: + description: + - The client trusted password. + - You need to set this password on the lxd server before + running this module using the following command + C(lxc config set core.trust_password <some random password>) + See U(https://www.stgraber.org/2016/04/18/lxd-api-direct-interaction/). + - If I(trust_password) is set, this module send a request for authentication before sending any requests. + type: str + state: + description: Filter the instance according to the current status. + type: str + default: none + choices: [ 'STOPPED', 'STARTING', 'RUNNING', 'none' ] + project: + description: Filter the instance according to the given project. + type: str + default: default + version_added: 6.2.0 + type_filter: + description: + - Filter the instances by type C(virtual-machine), C(container) or C(both). + - The first version of the inventory only supported containers. + type: str + default: container + choices: [ 'virtual-machine', 'container', 'both' ] + version_added: 4.2.0 + prefered_instance_network_interface: + description: + - If an instance has multiple network interfaces, select which one is the prefered as pattern. + - Combined with the first number that can be found e.g. 'eth' + 0. + - The option has been renamed from I(prefered_container_network_interface) to I(prefered_instance_network_interface) in community.general 3.8.0. + The old name still works as an alias. + type: str + default: eth + aliases: + - prefered_container_network_interface + prefered_instance_network_family: + description: + - If an instance has multiple network interfaces, which one is the prefered by family. + - Specify C(inet) for IPv4 and C(inet6) for IPv6. + type: str + default: inet + choices: [ 'inet', 'inet6' ] + groupby: + description: + - Create groups by the following keywords C(location), C(network_range), C(os), C(pattern), C(profile), C(release), C(type), C(vlanid). + - See example for syntax. + type: dict +''' + +EXAMPLES = ''' +# simple lxd.yml +plugin: community.general.lxd +url: unix:/var/snap/lxd/common/lxd/unix.socket + +# simple lxd.yml including filter +plugin: community.general.lxd +url: unix:/var/snap/lxd/common/lxd/unix.socket +state: RUNNING + +# simple lxd.yml including virtual machines and containers +plugin: community.general.lxd +url: unix:/var/snap/lxd/common/lxd/unix.socket +type_filter: both + +# grouping lxd.yml +groupby: + locationBerlin: + type: location + attribute: Berlin + netRangeIPv4: + type: network_range + attribute: 10.98.143.0/24 + netRangeIPv6: + type: network_range + attribute: fd42:bd00:7b11:2167:216:3eff::/24 + osUbuntu: + type: os + attribute: ubuntu + testpattern: + type: pattern + attribute: test + profileDefault: + type: profile + attribute: default + profileX11: + type: profile + attribute: x11 + releaseFocal: + type: release + attribute: focal + releaseBionic: + type: release + attribute: bionic + typeVM: + type: type + attribute: virtual-machine + typeContainer: + type: type + attribute: container + vlan666: + type: vlanid + attribute: 666 + projectInternals: + type: project + attribute: internals +''' + +import json +import re +import time +import os +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.module_utils.common.text.converters import to_native, to_text +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible.module_utils.six import raise_from +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible_collections.community.general.plugins.module_utils.lxd import LXDClient, LXDClientException + +try: + import ipaddress +except ImportError as exc: + IPADDRESS_IMPORT_ERROR = exc +else: + IPADDRESS_IMPORT_ERROR = None + + +class InventoryModule(BaseInventoryPlugin): + DEBUG = 4 + NAME = 'community.general.lxd' + SNAP_SOCKET_URL = 'unix:/var/snap/lxd/common/lxd/unix.socket' + SOCKET_URL = 'unix:/var/lib/lxd/unix.socket' + + @staticmethod + def load_json_data(path): + """Load json data + + Load json data from file + + Args: + list(path): Path elements + str(file_name): Filename of data + Kwargs: + None + Raises: + None + Returns: + dict(json_data): json data""" + try: + with open(path, 'r') as json_file: + return json.load(json_file) + except (IOError, json.decoder.JSONDecodeError) as err: + raise AnsibleParserError('Could not load the test data from {0}: {1}'.format(to_native(path), to_native(err))) + + def save_json_data(self, path, file_name=None): + """save data as json + + Save data as json file + + Args: + list(path): Path elements + str(file_name): Filename of data + Kwargs: + None + Raises: + None + Returns: + None""" + + if file_name: + path.append(file_name) + else: + prefix = 'lxd_data-' + time_stamp = time.strftime('%Y%m%d-%H%M%S') + suffix = '.atd' + path.append(prefix + time_stamp + suffix) + + try: + cwd = os.path.abspath(os.path.dirname(__file__)) + with open(os.path.abspath(os.path.join(cwd, *path)), 'w') as json_file: + json.dump(self.data, json_file) + except IOError as err: + raise AnsibleParserError('Could not save data: {0}'.format(to_native(err))) + + def verify_file(self, path): + """Check the config + + Return true/false if the config-file is valid for this plugin + + Args: + str(path): path to the config + Kwargs: + None + Raises: + None + Returns: + bool(valid): is valid""" + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('lxd.yaml', 'lxd.yml')): + valid = True + else: + self.display.vvv('Inventory source not ending in "lxd.yaml" or "lxd.yml"') + return valid + + @staticmethod + def validate_url(url): + """validate url + + check whether the url is correctly formatted + + Args: + url + Kwargs: + None + Raises: + AnsibleError + Returns: + bool""" + if not isinstance(url, str): + return False + if not url.startswith(('unix:', 'https:')): + raise AnsibleError('URL is malformed: {0}'.format(to_native(url))) + return True + + def _connect_to_socket(self): + """connect to lxd socket + + Connect to lxd socket by provided url or defaults + + Args: + None + Kwargs: + None + Raises: + AnsibleError + Returns: + None""" + error_storage = {} + url_list = [self.get_option('url'), self.SNAP_SOCKET_URL, self.SOCKET_URL] + urls = (url for url in url_list if self.validate_url(url)) + for url in urls: + try: + socket_connection = LXDClient(url, self.client_key, self.client_cert, self.debug) + return socket_connection + except LXDClientException as err: + error_storage[url] = err + raise AnsibleError('No connection to the socket: {0}'.format(to_native(error_storage))) + + def _get_networks(self): + """Get Networknames + + Returns all network config names + + Args: + None + Kwargs: + None + Raises: + None + Returns: + list(names): names of all network_configs""" + # e.g. {'type': 'sync', + # 'status': 'Success', + # 'status_code': 200, + # 'operation': '', + # 'error_code': 0, + # 'error': '', + # 'metadata': ['/1.0/networks/lxdbr0']} + network_configs = self.socket.do('GET', '/1.0/networks') + return [m.split('/')[3] for m in network_configs['metadata']] + + def _get_instances(self): + """Get instancenames + + Returns all instancenames + + Args: + None + Kwargs: + None + Raises: + None + Returns: + list(names): names of all instances""" + # e.g. { + # "metadata": [ + # "/1.0/instances/foo", + # "/1.0/instances/bar" + # ], + # "status": "Success", + # "status_code": 200, + # "type": "sync" + # } + url = '/1.0/instances' + if self.project: + url = url + '?{0}'.format(urlencode(dict(project=self.project))) + + instances = self.socket.do('GET', url) + + if self.project: + return [m.split('/')[3].split('?')[0] for m in instances['metadata']] + + return [m.split('/')[3] for m in instances['metadata']] + + def _get_config(self, branch, name): + """Get inventory of instance + + Get config of instance + + Args: + str(branch): Name oft the API-Branch + str(name): Name of instance + Kwargs: + None + Source: + https://github.com/lxc/lxd/blob/master/doc/rest-api.md + Raises: + None + Returns: + dict(config): Config of the instance""" + config = {} + if isinstance(branch, (tuple, list)): + config[name] = {branch[1]: self.socket.do( + 'GET', '/1.0/{0}/{1}/{2}?{3}'.format(to_native(branch[0]), to_native(name), to_native(branch[1]), urlencode(dict(project=self.project))))} + else: + config[name] = {branch: self.socket.do( + 'GET', '/1.0/{0}/{1}?{2}'.format(to_native(branch), to_native(name), urlencode(dict(project=self.project))))} + return config + + def get_instance_data(self, names): + """Create Inventory of the instance + + Iterate through the different branches of the instances and collect Informations. + + Args: + list(names): List of instance names + Kwargs: + None + Raises: + None + Returns: + None""" + # tuple(('instances','metadata/templates')) to get section in branch + # e.g. /1.0/instances/<name>/metadata/templates + branches = ['instances', ('instances', 'state')] + instance_config = {} + for branch in branches: + for name in names: + instance_config['instances'] = self._get_config(branch, name) + self.data = dict_merge(instance_config, self.data) + + def get_network_data(self, names): + """Create Inventory of the instance + + Iterate through the different branches of the instances and collect Informations. + + Args: + list(names): List of instance names + Kwargs: + None + Raises: + None + Returns: + None""" + # tuple(('instances','metadata/templates')) to get section in branch + # e.g. /1.0/instances/<name>/metadata/templates + branches = [('networks', 'state')] + network_config = {} + for branch in branches: + for name in names: + try: + network_config['networks'] = self._get_config(branch, name) + except LXDClientException: + network_config['networks'] = {name: None} + self.data = dict_merge(network_config, self.data) + + def extract_network_information_from_instance_config(self, instance_name): + """Returns the network interface configuration + + Returns the network ipv4 and ipv6 config of the instance without local-link + + Args: + str(instance_name): Name oft he instance + Kwargs: + None + Raises: + None + Returns: + dict(network_configuration): network config""" + instance_network_interfaces = self._get_data_entry('instances/{0}/state/metadata/network'.format(instance_name)) + network_configuration = None + if instance_network_interfaces: + network_configuration = {} + gen_interface_names = [interface_name for interface_name in instance_network_interfaces if interface_name != 'lo'] + for interface_name in gen_interface_names: + gen_address = [address for address in instance_network_interfaces[interface_name]['addresses'] if address.get('scope') != 'link'] + network_configuration[interface_name] = [] + for address in gen_address: + address_set = {} + address_set['family'] = address.get('family') + address_set['address'] = address.get('address') + address_set['netmask'] = address.get('netmask') + address_set['combined'] = address.get('address') + '/' + address.get('netmask') + network_configuration[interface_name].append(address_set) + return network_configuration + + def get_prefered_instance_network_interface(self, instance_name): + """Helper to get the prefered interface of thr instance + + Helper to get the prefered interface provide by neme pattern from 'prefered_instance_network_interface'. + + Args: + str(containe_name): name of instance + Kwargs: + None + Raises: + None + Returns: + str(prefered_interface): None or interface name""" + instance_network_interfaces = self._get_data_entry('inventory/{0}/network_interfaces'.format(instance_name)) + prefered_interface = None # init + if instance_network_interfaces: # instance have network interfaces + # generator if interfaces which start with the desired pattern + net_generator = [interface for interface in instance_network_interfaces if interface.startswith(self.prefered_instance_network_interface)] + selected_interfaces = [] # init + for interface in net_generator: + selected_interfaces.append(interface) + if len(selected_interfaces) > 0: + prefered_interface = sorted(selected_interfaces)[0] + return prefered_interface + + def get_instance_vlans(self, instance_name): + """Get VLAN(s) from instance + + Helper to get the VLAN_ID from the instance + + Args: + str(containe_name): name of instance + Kwargs: + None + Raises: + None + Returns: + None""" + # get network device configuration and store {network: vlan_id} + network_vlans = {} + for network in self._get_data_entry('networks'): + if self._get_data_entry('state/metadata/vlan/vid', data=self.data['networks'].get(network)): + network_vlans[network] = self._get_data_entry('state/metadata/vlan/vid', data=self.data['networks'].get(network)) + + # get networkdevices of instance and return + # e.g. + # "eth0":{ "name":"eth0", + # "network":"lxdbr0", + # "type":"nic"}, + vlan_ids = {} + devices = self._get_data_entry('instances/{0}/instances/metadata/expanded_devices'.format(to_native(instance_name))) + for device in devices: + if 'network' in devices[device]: + if devices[device]['network'] in network_vlans: + vlan_ids[devices[device].get('network')] = network_vlans[devices[device].get('network')] + return vlan_ids if vlan_ids else None + + def _get_data_entry(self, path, data=None, delimiter='/'): + """Helper to get data + + Helper to get data from self.data by a path like 'path/to/target' + Attention: Escaping of the delimiter is not (yet) provided. + + Args: + str(path): path to nested dict + Kwargs: + dict(data): datastore + str(delimiter): delimiter in Path. + Raises: + None + Returns: + *(value)""" + try: + if not data: + data = self.data + if delimiter in path: + path = path.split(delimiter) + + if isinstance(path, list) and len(path) > 1: + data = data[path.pop(0)] + path = delimiter.join(path) + return self._get_data_entry(path, data, delimiter) # recursion + return data[path] + except KeyError: + return None + + def _set_data_entry(self, instance_name, key, value, path=None): + """Helper to save data + + Helper to save the data in self.data + Detect if data is already in branch and use dict_merge() to prevent that branch is overwritten. + + Args: + str(instance_name): name of instance + str(key): same as dict + *(value): same as dict + Kwargs: + str(path): path to branch-part + Raises: + AnsibleParserError + Returns: + None""" + if not path: + path = self.data['inventory'] + if instance_name not in path: + path[instance_name] = {} + + try: + if isinstance(value, dict) and key in path[instance_name]: + path[instance_name] = dict_merge(value, path[instance_name][key]) + else: + path[instance_name][key] = value + except KeyError as err: + raise AnsibleParserError("Unable to store Informations: {0}".format(to_native(err))) + + def extract_information_from_instance_configs(self): + """Process configuration information + + Preparation of the data + + Args: + dict(configs): instance configurations + Kwargs: + None + Raises: + None + Returns: + None""" + # create branch "inventory" + if 'inventory' not in self.data: + self.data['inventory'] = {} + + for instance_name in self.data['instances']: + self._set_data_entry(instance_name, 'os', self._get_data_entry( + 'instances/{0}/instances/metadata/config/image.os'.format(instance_name))) + self._set_data_entry(instance_name, 'release', self._get_data_entry( + 'instances/{0}/instances/metadata/config/image.release'.format(instance_name))) + self._set_data_entry(instance_name, 'version', self._get_data_entry( + 'instances/{0}/instances/metadata/config/image.version'.format(instance_name))) + self._set_data_entry(instance_name, 'profile', self._get_data_entry( + 'instances/{0}/instances/metadata/profiles'.format(instance_name))) + self._set_data_entry(instance_name, 'location', self._get_data_entry( + 'instances/{0}/instances/metadata/location'.format(instance_name))) + self._set_data_entry(instance_name, 'state', self._get_data_entry( + 'instances/{0}/instances/metadata/config/volatile.last_state.power'.format(instance_name))) + self._set_data_entry(instance_name, 'type', self._get_data_entry( + 'instances/{0}/instances/metadata/type'.format(instance_name))) + self._set_data_entry(instance_name, 'network_interfaces', self.extract_network_information_from_instance_config(instance_name)) + self._set_data_entry(instance_name, 'preferred_interface', self.get_prefered_instance_network_interface(instance_name)) + self._set_data_entry(instance_name, 'vlan_ids', self.get_instance_vlans(instance_name)) + self._set_data_entry(instance_name, 'project', self._get_data_entry( + 'instances/{0}/instances/metadata/project'.format(instance_name))) + + def build_inventory_network(self, instance_name): + """Add the network interfaces of the instance to the inventory + + Logic: + - if the instance have no interface -> 'ansible_connection: local' + - get preferred_interface & prefered_instance_network_family -> 'ansible_connection: ssh' & 'ansible_host: <IP>' + - first Interface from: network_interfaces prefered_instance_network_family -> 'ansible_connection: ssh' & 'ansible_host: <IP>' + + Args: + str(instance_name): name of instance + Kwargs: + None + Raises: + None + Returns: + None""" + + def interface_selection(instance_name): + """Select instance Interface for inventory + + Logic: + - get preferred_interface & prefered_instance_network_family -> str(IP) + - first Interface from: network_interfaces prefered_instance_network_family -> str(IP) + + Args: + str(instance_name): name of instance + Kwargs: + None + Raises: + None + Returns: + dict(interface_name: ip)""" + prefered_interface = self._get_data_entry('inventory/{0}/preferred_interface'.format(instance_name)) # name or None + prefered_instance_network_family = self.prefered_instance_network_family + + ip_address = '' + if prefered_interface: + interface = self._get_data_entry('inventory/{0}/network_interfaces/{1}'.format(instance_name, prefered_interface)) + for config in interface: + if config['family'] == prefered_instance_network_family: + ip_address = config['address'] + break + else: + interfaces = self._get_data_entry('inventory/{0}/network_interfaces'.format(instance_name)) + for interface in interfaces.values(): + for config in interface: + if config['family'] == prefered_instance_network_family: + ip_address = config['address'] + break + return ip_address + + if self._get_data_entry('inventory/{0}/network_interfaces'.format(instance_name)): # instance have network interfaces + self.inventory.set_variable(instance_name, 'ansible_connection', 'ssh') + self.inventory.set_variable(instance_name, 'ansible_host', interface_selection(instance_name)) + else: + self.inventory.set_variable(instance_name, 'ansible_connection', 'local') + + def build_inventory_hosts(self): + """Build host-part dynamic inventory + + Build the host-part of the dynamic inventory. + Add Hosts and host_vars to the inventory. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + for instance_name in self.data['inventory']: + instance_state = str(self._get_data_entry('inventory/{0}/state'.format(instance_name)) or "STOPPED").lower() + + # Only consider instances that match the "state" filter, if self.state is not None + if self.filter: + if self.filter.lower() != instance_state: + continue + # add instance + self.inventory.add_host(instance_name) + # add network informations + self.build_inventory_network(instance_name) + # add os + v = self._get_data_entry('inventory/{0}/os'.format(instance_name)) + if v: + self.inventory.set_variable(instance_name, 'ansible_lxd_os', v.lower()) + # add release + v = self._get_data_entry('inventory/{0}/release'.format(instance_name)) + if v: + self.inventory.set_variable(instance_name, 'ansible_lxd_release', v.lower()) + # add profile + self.inventory.set_variable(instance_name, 'ansible_lxd_profile', self._get_data_entry('inventory/{0}/profile'.format(instance_name))) + # add state + self.inventory.set_variable(instance_name, 'ansible_lxd_state', instance_state) + # add type + self.inventory.set_variable(instance_name, 'ansible_lxd_type', self._get_data_entry('inventory/{0}/type'.format(instance_name))) + # add location information + if self._get_data_entry('inventory/{0}/location'.format(instance_name)) != "none": # wrong type by lxd 'none' != 'None' + self.inventory.set_variable(instance_name, 'ansible_lxd_location', self._get_data_entry('inventory/{0}/location'.format(instance_name))) + # add VLAN_ID information + if self._get_data_entry('inventory/{0}/vlan_ids'.format(instance_name)): + self.inventory.set_variable(instance_name, 'ansible_lxd_vlan_ids', self._get_data_entry('inventory/{0}/vlan_ids'.format(instance_name))) + # add project + self.inventory.set_variable(instance_name, 'ansible_lxd_project', self._get_data_entry('inventory/{0}/project'.format(instance_name))) + + def build_inventory_groups_location(self, group_name): + """create group by attribute: location + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + for instance_name in self.inventory.hosts: + if 'ansible_lxd_location' in self.inventory.get_host(instance_name).get_vars(): + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups_pattern(self, group_name): + """create group by name pattern + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + regex_pattern = self.groupby[group_name].get('attribute') + + for instance_name in self.inventory.hosts: + result = re.search(regex_pattern, instance_name) + if result: + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups_network_range(self, group_name): + """check if IP is in network-class + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + try: + network = ipaddress.ip_network(to_text(self.groupby[group_name].get('attribute'))) + except ValueError as err: + raise AnsibleParserError( + 'Error while parsing network range {0}: {1}'.format(self.groupby[group_name].get('attribute'), to_native(err))) + + for instance_name in self.inventory.hosts: + if self.data['inventory'][instance_name].get('network_interfaces') is not None: + for interface in self.data['inventory'][instance_name].get('network_interfaces'): + for interface_family in self.data['inventory'][instance_name].get('network_interfaces')[interface]: + try: + address = ipaddress.ip_address(to_text(interface_family['address'])) + if address.version == network.version and address in network: + self.inventory.add_child(group_name, instance_name) + except ValueError: + # Ignore invalid IP addresses returned by lxd + pass + + def build_inventory_groups_project(self, group_name): + """create group by attribute: project + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_instances = [ + instance_name for instance_name in self.inventory.hosts + if 'ansible_lxd_project' in self.inventory.get_host(instance_name).get_vars()] + for instance_name in gen_instances: + if self.groupby[group_name].get('attribute').lower() == self.inventory.get_host(instance_name).get_vars().get('ansible_lxd_project'): + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups_os(self, group_name): + """create group by attribute: os + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_instances = [ + instance_name for instance_name in self.inventory.hosts + if 'ansible_lxd_os' in self.inventory.get_host(instance_name).get_vars()] + for instance_name in gen_instances: + if self.groupby[group_name].get('attribute').lower() == self.inventory.get_host(instance_name).get_vars().get('ansible_lxd_os'): + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups_release(self, group_name): + """create group by attribute: release + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_instances = [ + instance_name for instance_name in self.inventory.hosts + if 'ansible_lxd_release' in self.inventory.get_host(instance_name).get_vars()] + for instance_name in gen_instances: + if self.groupby[group_name].get('attribute').lower() == self.inventory.get_host(instance_name).get_vars().get('ansible_lxd_release'): + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups_profile(self, group_name): + """create group by attribute: profile + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_instances = [ + instance_name for instance_name in self.inventory.hosts.keys() + if 'ansible_lxd_profile' in self.inventory.get_host(instance_name).get_vars().keys()] + for instance_name in gen_instances: + if self.groupby[group_name].get('attribute').lower() in self.inventory.get_host(instance_name).get_vars().get('ansible_lxd_profile'): + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups_vlanid(self, group_name): + """create group by attribute: vlanid + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_instances = [ + instance_name for instance_name in self.inventory.hosts.keys() + if 'ansible_lxd_vlan_ids' in self.inventory.get_host(instance_name).get_vars().keys()] + for instance_name in gen_instances: + if self.groupby[group_name].get('attribute') in self.inventory.get_host(instance_name).get_vars().get('ansible_lxd_vlan_ids').values(): + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups_type(self, group_name): + """create group by attribute: type + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + # maybe we just want to expand one group + if group_name not in self.inventory.groups: + self.inventory.add_group(group_name) + + gen_instances = [ + instance_name for instance_name in self.inventory.hosts + if 'ansible_lxd_type' in self.inventory.get_host(instance_name).get_vars()] + for instance_name in gen_instances: + if self.groupby[group_name].get('attribute').lower() == self.inventory.get_host(instance_name).get_vars().get('ansible_lxd_type'): + self.inventory.add_child(group_name, instance_name) + + def build_inventory_groups(self): + """Build group-part dynamic inventory + + Build the group-part of the dynamic inventory. + Add groups to the inventory. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + + def group_type(group_name): + """create groups defined by lxd.yml or defaultvalues + + create groups defined by lxd.yml or defaultvalues + supportetd: + * 'location' + * 'pattern' + * 'network_range' + * 'os' + * 'release' + * 'profile' + * 'vlanid' + * 'type' + * 'project' + + Args: + str(group_name): Group name + Kwargs: + None + Raises: + None + Returns: + None""" + + # Due to the compatibility with python 2 no use of map + if self.groupby[group_name].get('type') == 'location': + self.build_inventory_groups_location(group_name) + elif self.groupby[group_name].get('type') == 'pattern': + self.build_inventory_groups_pattern(group_name) + elif self.groupby[group_name].get('type') == 'network_range': + self.build_inventory_groups_network_range(group_name) + elif self.groupby[group_name].get('type') == 'os': + self.build_inventory_groups_os(group_name) + elif self.groupby[group_name].get('type') == 'release': + self.build_inventory_groups_release(group_name) + elif self.groupby[group_name].get('type') == 'profile': + self.build_inventory_groups_profile(group_name) + elif self.groupby[group_name].get('type') == 'vlanid': + self.build_inventory_groups_vlanid(group_name) + elif self.groupby[group_name].get('type') == 'type': + self.build_inventory_groups_type(group_name) + elif self.groupby[group_name].get('type') == 'project': + self.build_inventory_groups_project(group_name) + else: + raise AnsibleParserError('Unknown group type: {0}'.format(to_native(group_name))) + + if self.groupby: + for group_name in self.groupby: + if not group_name.isalnum(): + raise AnsibleParserError('Invalid character(s) in groupname: {0}'.format(to_native(group_name))) + group_type(group_name) + + def build_inventory(self): + """Build dynamic inventory + + Build the dynamic inventory. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + + self.build_inventory_hosts() + self.build_inventory_groups() + + def cleandata(self): + """Clean the dynamic inventory + + The first version of the inventory only supported container. + This will change in the future. + The following function cleans up the data and remove the all items with the wrong type. + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + iter_keys = list(self.data['instances'].keys()) + for instance_name in iter_keys: + if self._get_data_entry('instances/{0}/instances/metadata/type'.format(instance_name)) != self.type_filter: + del self.data['instances'][instance_name] + + def _populate(self): + """Return the hosts and groups + + Returns the processed instance configurations from the lxd import + + Args: + None + Kwargs: + None + Raises: + None + Returns: + None""" + + if len(self.data) == 0: # If no data is injected by unittests open socket + self.socket = self._connect_to_socket() + self.get_instance_data(self._get_instances()) + self.get_network_data(self._get_networks()) + + # The first version of the inventory only supported containers. + # This will change in the future. + # The following function cleans up the data. + if self.type_filter != 'both': + self.cleandata() + + self.extract_information_from_instance_configs() + + # self.display.vvv(self.save_json_data([os.path.abspath(__file__)])) + + self.build_inventory() + + def parse(self, inventory, loader, path, cache): + """Return dynamic inventory from source + + Returns the processed inventory from the lxd import + + Args: + str(inventory): inventory object with existing data and + the methods to add hosts/groups/variables + to inventory + str(loader): Ansible's DataLoader + str(path): path to the config + bool(cache): use or avoid caches + Kwargs: + None + Raises: + AnsibleParserError + Returns: + None""" + if IPADDRESS_IMPORT_ERROR: + raise_from( + AnsibleError('another_library must be installed to use this plugin'), + IPADDRESS_IMPORT_ERROR) + + super(InventoryModule, self).parse(inventory, loader, path, cache=False) + # Read the inventory YAML file + self._read_config_data(path) + try: + self.client_key = self.get_option('client_key') + self.client_cert = self.get_option('client_cert') + self.project = self.get_option('project') + self.debug = self.DEBUG + self.data = {} # store for inventory-data + self.groupby = self.get_option('groupby') + self.plugin = self.get_option('plugin') + self.prefered_instance_network_family = self.get_option('prefered_instance_network_family') + self.prefered_instance_network_interface = self.get_option('prefered_instance_network_interface') + self.type_filter = self.get_option('type_filter') + if self.get_option('state').lower() == 'none': # none in config is str() + self.filter = None + else: + self.filter = self.get_option('state').lower() + self.trust_password = self.get_option('trust_password') + self.url = self.get_option('url') + except Exception as err: + raise AnsibleParserError( + 'All correct options required: {0}'.format(to_native(err))) + # Call our internal helper to populate the dynamic inventory + self._populate() diff --git a/ansible_collections/community/general/plugins/inventory/nmap.py b/ansible_collections/community/general/plugins/inventory/nmap.py new file mode 100644 index 000000000..a03cf3e6f --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/nmap.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + author: Unknown (!UNKNOWN) + name: nmap + short_description: Uses nmap to find hosts to target + description: + - Uses a YAML configuration file with a valid YAML extension. + extends_documentation_fragment: + - constructed + - inventory_cache + requirements: + - nmap CLI installed + options: + plugin: + description: token that ensures this is a source file for the 'nmap' plugin. + required: true + choices: ['nmap', 'community.general.nmap'] + sudo: + description: Set to C(true) to execute a C(sudo nmap) plugin scan. + version_added: 4.8.0 + default: false + type: boolean + address: + description: Network IP or range of IPs to scan, you can use a simple range (10.2.2.15-25) or CIDR notation. + required: true + env: + - name: ANSIBLE_NMAP_ADDRESS + version_added: 6.6.0 + exclude: + description: + - List of addresses to exclude. + - For example C(10.2.2.15-25) or C(10.2.2.15,10.2.2.16). + type: list + elements: string + env: + - name: ANSIBLE_NMAP_EXCLUDE + version_added: 6.6.0 + port: + description: + - Only scan specific port or port range (C(-p)). + - For example, you could pass C(22) for a single port, C(1-65535) for a range of ports, + or C(U:53,137,T:21-25,139,8080,S:9) to check port 53 with UDP, ports 21-25 with TCP, port 9 with SCTP, and ports 137, 139, and 8080 with all. + type: string + version_added: 6.5.0 + ports: + description: Enable/disable scanning ports. + type: boolean + default: true + ipv4: + description: use IPv4 type addresses + type: boolean + default: true + ipv6: + description: use IPv6 type addresses + type: boolean + default: true + udp_scan: + description: + - Scan via UDP. + - Depending on your system you might need I(sudo=true) for this to work. + type: boolean + default: false + version_added: 6.1.0 + icmp_timestamp: + description: + - Scan via ICMP Timestamp (C(-PP)). + - Depending on your system you might need I(sudo=true) for this to work. + type: boolean + default: false + version_added: 6.1.0 + open: + description: Only scan for open (or possibly open) ports. + type: boolean + default: false + version_added: 6.5.0 + dns_resolve: + description: Whether to always (C(true)) or never (C(false)) do DNS resolution. + type: boolean + default: false + version_added: 6.1.0 + notes: + - At least one of ipv4 or ipv6 is required to be True, both can be True, but they cannot both be False. + - 'TODO: add OS fingerprinting' +''' +EXAMPLES = ''' +# inventory.config file in YAML format +plugin: community.general.nmap +strict: false +address: 192.168.0.0/24 + + +# a sudo nmap scan to fully use nmap scan power. +plugin: community.general.nmap +sudo: true +strict: false +address: 192.168.0.0/24 + +# an nmap scan specifying ports and classifying results to an inventory group +plugin: community.general.nmap +address: 192.168.0.0/24 +exclude: 192.168.0.1, web.example.com +port: 22, 443 +groups: + web_servers: "ports | selectattr('port', 'equalto', '443')" +''' + +import os +import re + +from subprocess import Popen, PIPE + +from ansible import constants as C +from ansible.errors import AnsibleParserError +from ansible.module_utils.common.text.converters import to_native, to_text +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable +from ansible.module_utils.common.process import get_bin_path + + +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): + + NAME = 'community.general.nmap' + find_host = re.compile(r'^Nmap scan report for ([\w,.,-]+)(?: \(([\w,.,:,\[,\]]+)\))?') + find_port = re.compile(r'^(\d+)/(\w+)\s+(\w+)\s+(\w+)') + + def __init__(self): + self._nmap = None + super(InventoryModule, self).__init__() + + def _populate(self, hosts): + # Use constructed if applicable + strict = self.get_option('strict') + + for host in hosts: + hostname = host['name'] + self.inventory.add_host(hostname) + for var, value in host.items(): + self.inventory.set_variable(hostname, var, value) + + # Composed variables + self._set_composite_vars(self.get_option('compose'), host, hostname, strict=strict) + + # Complex groups based on jinja2 conditionals, hosts that meet the conditional are added to group + self._add_host_to_composed_groups(self.get_option('groups'), host, hostname, strict=strict) + + # Create groups based on variable values and add the corresponding hosts to it + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), host, hostname, strict=strict) + + def verify_file(self, path): + + valid = False + if super(InventoryModule, self).verify_file(path): + file_name, ext = os.path.splitext(path) + + if not ext or ext in C.YAML_FILENAME_EXTENSIONS: + valid = True + + return valid + + def parse(self, inventory, loader, path, cache=True): + + try: + self._nmap = get_bin_path('nmap') + except ValueError as e: + raise AnsibleParserError('nmap inventory plugin requires the nmap cli tool to work: {0}'.format(to_native(e))) + + super(InventoryModule, self).parse(inventory, loader, path, cache=cache) + + self._read_config_data(path) + + cache_key = self.get_cache_key(path) + + # cache may be True or False at this point to indicate if the inventory is being refreshed + # get the user's cache option too to see if we should save the cache if it is changing + user_cache_setting = self.get_option('cache') + + # read if the user has caching enabled and the cache isn't being refreshed + attempt_to_read_cache = user_cache_setting and cache + # update if the user has caching enabled and the cache is being refreshed; update this value to True if the cache has expired below + cache_needs_update = user_cache_setting and not cache + + if attempt_to_read_cache: + try: + results = self._cache[cache_key] + except KeyError: + # This occurs if the cache_key is not in the cache or if the cache_key expired, so the cache needs to be updated + cache_needs_update = True + + if not user_cache_setting or cache_needs_update: + # setup command + cmd = [self._nmap] + + if self._options['sudo']: + cmd.insert(0, 'sudo') + + if self._options['port']: + cmd.append('-p') + cmd.append(self._options['port']) + + if not self._options['ports']: + cmd.append('-sP') + + if self._options['ipv4'] and not self._options['ipv6']: + cmd.append('-4') + elif self._options['ipv6'] and not self._options['ipv4']: + cmd.append('-6') + elif not self._options['ipv6'] and not self._options['ipv4']: + raise AnsibleParserError('One of ipv4 or ipv6 must be enabled for this plugin') + + if self._options['exclude']: + cmd.append('--exclude') + cmd.append(','.join(self._options['exclude'])) + + if self._options['dns_resolve']: + cmd.append('-n') + + if self._options['udp_scan']: + cmd.append('-sU') + + if self._options['icmp_timestamp']: + cmd.append('-PP') + + if self._options['open']: + cmd.append('--open') + + cmd.append(self._options['address']) + try: + # execute + p = Popen(cmd, stdout=PIPE, stderr=PIPE) + stdout, stderr = p.communicate() + if p.returncode != 0: + raise AnsibleParserError('Failed to run nmap, rc=%s: %s' % (p.returncode, to_native(stderr))) + + # parse results + host = None + ip = None + ports = [] + results = [] + + try: + t_stdout = to_text(stdout, errors='surrogate_or_strict') + except UnicodeError as e: + raise AnsibleParserError('Invalid (non unicode) input returned: %s' % to_native(e)) + + for line in t_stdout.splitlines(): + hits = self.find_host.match(line) + if hits: + if host is not None and ports: + results[-1]['ports'] = ports + + # if dns only shows arpa, just use ip instead as hostname + if hits.group(1).endswith('.in-addr.arpa'): + host = hits.group(2) + else: + host = hits.group(1) + + # if no reverse dns exists, just use ip instead as hostname + if hits.group(2) is not None: + ip = hits.group(2) + else: + ip = hits.group(1) + + if host is not None: + # update inventory + results.append(dict()) + results[-1]['name'] = host + results[-1]['ip'] = ip + ports = [] + continue + + host_ports = self.find_port.match(line) + if host is not None and host_ports: + ports.append({'port': host_ports.group(1), + 'protocol': host_ports.group(2), + 'state': host_ports.group(3), + 'service': host_ports.group(4)}) + continue + + # if any leftovers + if host and ports: + results[-1]['ports'] = ports + + except Exception as e: + raise AnsibleParserError("failed to parse %s: %s " % (to_native(path), to_native(e))) + + if cache_needs_update: + self._cache[cache_key] = results + + self._populate(results) diff --git a/ansible_collections/community/general/plugins/inventory/online.py b/ansible_collections/community/general/plugins/inventory/online.py new file mode 100644 index 000000000..3fccd58d2 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/online.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = r''' + name: online + author: + - Remy Leone (@remyleone) + short_description: Scaleway (previously Online SAS or Online.net) inventory source + description: + - Get inventory hosts from Scaleway (previously Online SAS or Online.net). + options: + plugin: + description: token that ensures this is a source file for the 'online' plugin. + required: true + choices: ['online', 'community.general.online'] + oauth_token: + required: true + description: Online OAuth token. + env: + # in order of precedence + - name: ONLINE_TOKEN + - name: ONLINE_API_KEY + - name: ONLINE_OAUTH_TOKEN + hostnames: + description: List of preference about what to use as an hostname. + type: list + elements: string + default: + - public_ipv4 + choices: + - public_ipv4 + - private_ipv4 + - hostname + groups: + description: List of groups. + type: list + elements: string + choices: + - location + - offer + - rpn +''' + +EXAMPLES = r''' +# online_inventory.yml file in YAML format +# Example command line: ansible-inventory --list -i online_inventory.yml + +plugin: community.general.online +hostnames: + - public_ipv4 +groups: + - location + - offer + - rpn +''' + +import json +from sys import version as python_version + +from ansible.errors import AnsibleError +from ansible.module_utils.urls import open_url +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.module_utils.common.text.converters import to_text +from ansible.module_utils.ansible_release import __version__ as ansible_version +from ansible.module_utils.six.moves.urllib.parse import urljoin + + +class InventoryModule(BaseInventoryPlugin): + NAME = 'community.general.online' + API_ENDPOINT = "https://api.online.net" + + def extract_public_ipv4(self, host_infos): + try: + return host_infos["network"]["ip"][0] + except (KeyError, TypeError, IndexError): + self.display.warning("An error happened while extracting public IPv4 address. Information skipped.") + return None + + def extract_private_ipv4(self, host_infos): + try: + return host_infos["network"]["private"][0] + except (KeyError, TypeError, IndexError): + self.display.warning("An error happened while extracting private IPv4 address. Information skipped.") + return None + + def extract_os_name(self, host_infos): + try: + return host_infos["os"]["name"] + except (KeyError, TypeError): + self.display.warning("An error happened while extracting OS name. Information skipped.") + return None + + def extract_os_version(self, host_infos): + try: + return host_infos["os"]["version"] + except (KeyError, TypeError): + self.display.warning("An error happened while extracting OS version. Information skipped.") + return None + + def extract_hostname(self, host_infos): + try: + return host_infos["hostname"] + except (KeyError, TypeError): + self.display.warning("An error happened while extracting hostname. Information skipped.") + return None + + def extract_location(self, host_infos): + try: + return host_infos["location"]["datacenter"] + except (KeyError, TypeError): + self.display.warning("An error happened while extracting datacenter location. Information skipped.") + return None + + def extract_offer(self, host_infos): + try: + return host_infos["offer"] + except (KeyError, TypeError): + self.display.warning("An error happened while extracting commercial offer. Information skipped.") + return None + + def extract_rpn(self, host_infos): + try: + return self.rpn_lookup_cache[host_infos["id"]] + except (KeyError, TypeError): + self.display.warning("An error happened while extracting RPN information. Information skipped.") + return None + + def _fetch_information(self, url): + try: + response = open_url(url, headers=self.headers) + except Exception as e: + self.display.warning("An error happened while fetching: %s" % url) + return None + + try: + raw_data = to_text(response.read(), errors='surrogate_or_strict') + except UnicodeError: + raise AnsibleError("Incorrect encoding of fetched payload from Online servers") + + try: + return json.loads(raw_data) + except ValueError: + raise AnsibleError("Incorrect JSON payload") + + @staticmethod + def extract_rpn_lookup_cache(rpn_list): + lookup = {} + for rpn in rpn_list: + for member in rpn["members"]: + lookup[member["id"]] = rpn["name"] + return lookup + + def _fill_host_variables(self, hostname, host_infos): + targeted_attributes = ( + "offer", + "id", + "hostname", + "location", + "boot_mode", + "power", + "last_reboot", + "anti_ddos", + "hardware_watch", + "support" + ) + for attribute in targeted_attributes: + self.inventory.set_variable(hostname, attribute, host_infos[attribute]) + + if self.extract_public_ipv4(host_infos=host_infos): + self.inventory.set_variable(hostname, "public_ipv4", self.extract_public_ipv4(host_infos=host_infos)) + self.inventory.set_variable(hostname, "ansible_host", self.extract_public_ipv4(host_infos=host_infos)) + + if self.extract_private_ipv4(host_infos=host_infos): + self.inventory.set_variable(hostname, "public_ipv4", self.extract_private_ipv4(host_infos=host_infos)) + + if self.extract_os_name(host_infos=host_infos): + self.inventory.set_variable(hostname, "os_name", self.extract_os_name(host_infos=host_infos)) + + if self.extract_os_version(host_infos=host_infos): + self.inventory.set_variable(hostname, "os_version", self.extract_os_name(host_infos=host_infos)) + + def _filter_host(self, host_infos, hostname_preferences): + + for pref in hostname_preferences: + if self.extractors[pref](host_infos): + return self.extractors[pref](host_infos) + + return None + + def do_server_inventory(self, host_infos, hostname_preferences, group_preferences): + + hostname = self._filter_host(host_infos=host_infos, + hostname_preferences=hostname_preferences) + + # No suitable hostname were found in the attributes and the host won't be in the inventory + if not hostname: + return + + self.inventory.add_host(host=hostname) + self._fill_host_variables(hostname=hostname, host_infos=host_infos) + + for g in group_preferences: + group = self.group_extractors[g](host_infos) + + if not group: + return + + self.inventory.add_group(group=group) + self.inventory.add_host(group=group, host=hostname) + + def parse(self, inventory, loader, path, cache=True): + super(InventoryModule, self).parse(inventory, loader, path) + self._read_config_data(path=path) + + token = self.get_option("oauth_token") + hostname_preferences = self.get_option("hostnames") + + group_preferences = self.get_option("groups") + if group_preferences is None: + group_preferences = [] + + self.extractors = { + "public_ipv4": self.extract_public_ipv4, + "private_ipv4": self.extract_private_ipv4, + "hostname": self.extract_hostname, + } + + self.group_extractors = { + "location": self.extract_location, + "offer": self.extract_offer, + "rpn": self.extract_rpn + } + + self.headers = { + 'Authorization': "Bearer %s" % token, + 'User-Agent': "ansible %s Python %s" % (ansible_version, python_version.split(' ', 1)[0]), + 'Content-type': 'application/json' + } + + servers_url = urljoin(InventoryModule.API_ENDPOINT, "api/v1/server") + servers_api_path = self._fetch_information(url=servers_url) + + if "rpn" in group_preferences: + rpn_groups_url = urljoin(InventoryModule.API_ENDPOINT, "api/v1/rpn/group") + rpn_list = self._fetch_information(url=rpn_groups_url) + self.rpn_lookup_cache = self.extract_rpn_lookup_cache(rpn_list) + + for server_api_path in servers_api_path: + + server_url = urljoin(InventoryModule.API_ENDPOINT, server_api_path) + raw_server_info = self._fetch_information(url=server_url) + + if raw_server_info is None: + continue + + self.do_server_inventory(host_infos=raw_server_info, + hostname_preferences=hostname_preferences, + group_preferences=group_preferences) diff --git a/ansible_collections/community/general/plugins/inventory/opennebula.py b/ansible_collections/community/general/plugins/inventory/opennebula.py new file mode 100644 index 000000000..603920edc --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/opennebula.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, FELDSAM s.r.o. - FeldHost™ <support@feldhost.cz> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +DOCUMENTATION = r''' + name: opennebula + author: + - Kristian Feldsam (@feldsam) + short_description: OpenNebula inventory source + version_added: "3.8.0" + extends_documentation_fragment: + - constructed + description: + - Get inventory hosts from OpenNebula cloud. + - Uses an YAML configuration file ending with either I(opennebula.yml) or I(opennebula.yaml) + to set parameter values. + - Uses I(api_authfile), C(~/.one/one_auth), or C(ONE_AUTH) pointing to a OpenNebula credentials file. + options: + plugin: + description: Token that ensures this is a source file for the 'opennebula' plugin. + type: string + required: true + choices: [ community.general.opennebula ] + api_url: + description: + - URL of the OpenNebula RPC server. + - It is recommended to use HTTPS so that the username/password are not + transferred over the network unencrypted. + - If not set then the value of the C(ONE_URL) environment variable is used. + env: + - name: ONE_URL + required: true + type: string + api_username: + description: + - Name of the user to login into the OpenNebula RPC server. If not set + then the value of the C(ONE_USERNAME) environment variable is used. + env: + - name: ONE_USERNAME + type: string + api_password: + description: + - Password or a token of the user to login into OpenNebula RPC server. + - If not set, the value of the C(ONE_PASSWORD) environment variable is used. + env: + - name: ONE_PASSWORD + required: false + type: string + api_authfile: + description: + - If both I(api_username) or I(api_password) are not set, then it will try + authenticate with ONE auth file. Default path is C(~/.one/one_auth). + - Set environment variable C(ONE_AUTH) to override this path. + env: + - name: ONE_AUTH + required: false + type: string + hostname: + description: Field to match the hostname. Note C(v4_first_ip) corresponds to the first IPv4 found on VM. + type: string + default: v4_first_ip + choices: + - v4_first_ip + - v6_first_ip + - name + filter_by_label: + description: Only return servers filtered by this label. + type: string + group_by_labels: + description: Create host groups by vm labels + type: bool + default: true +''' + +EXAMPLES = r''' +# inventory_opennebula.yml file in YAML format +# Example command line: ansible-inventory --list -i inventory_opennebula.yml + +# Pass a label filter to the API +plugin: community.general.opennebula +api_url: https://opennebula:2633/RPC2 +filter_by_label: Cache +''' + +try: + import pyone + + HAS_PYONE = True +except ImportError: + HAS_PYONE = False + +from ansible.errors import AnsibleError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable +from ansible.module_utils.common.text.converters import to_native + +from collections import namedtuple +import os + + +class InventoryModule(BaseInventoryPlugin, Constructable): + NAME = 'community.general.opennebula' + + def verify_file(self, path): + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('opennebula.yaml', 'opennebula.yml')): + valid = True + return valid + + def _get_connection_info(self): + url = self.get_option('api_url') + username = self.get_option('api_username') + password = self.get_option('api_password') + authfile = self.get_option('api_authfile') + + if not username and not password: + if authfile is None: + authfile = os.path.join(os.environ.get("HOME"), ".one", "one_auth") + try: + with open(authfile, "r") as fp: + authstring = fp.read().rstrip() + username, password = authstring.split(":") + except (OSError, IOError): + raise AnsibleError("Could not find or read ONE_AUTH file at '{e}'".format(e=authfile)) + except Exception: + raise AnsibleError("Error occurs when reading ONE_AUTH file at '{e}'".format(e=authfile)) + + auth_params = namedtuple('auth', ('url', 'username', 'password')) + + return auth_params(url=url, username=username, password=password) + + def _get_vm_ipv4(self, vm): + nic = vm.TEMPLATE.get('NIC') + + if isinstance(nic, dict): + nic = [nic] + + for net in nic: + return net['IP'] + + return False + + def _get_vm_ipv6(self, vm): + nic = vm.TEMPLATE.get('NIC') + + if isinstance(nic, dict): + nic = [nic] + + for net in nic: + if net.get('IP6_GLOBAL'): + return net['IP6_GLOBAL'] + + return False + + def _get_vm_pool(self): + auth = self._get_connection_info() + + if not (auth.username and auth.password): + raise AnsibleError('API Credentials missing. Check OpenNebula inventory file.') + else: + one_client = pyone.OneServer(auth.url, session=auth.username + ':' + auth.password) + + # get hosts (VMs) + try: + vm_pool = one_client.vmpool.infoextended(-2, -1, -1, 3) + except Exception as e: + raise AnsibleError("Something happened during XML-RPC call: {e}".format(e=to_native(e))) + + return vm_pool + + def _retrieve_servers(self, label_filter=None): + vm_pool = self._get_vm_pool() + + result = [] + + # iterate over hosts + for vm in vm_pool.VM: + server = vm.USER_TEMPLATE + + labels = [] + if vm.USER_TEMPLATE.get('LABELS'): + labels = [s for s in vm.USER_TEMPLATE.get('LABELS') if s == ',' or s == '-' or s.isalnum() or s.isspace()] + labels = ''.join(labels) + labels = labels.replace(' ', '_') + labels = labels.replace('-', '_') + labels = labels.split(',') + + # filter by label + if label_filter is not None: + if label_filter not in labels: + continue + + server['name'] = vm.NAME + server['LABELS'] = labels + server['v4_first_ip'] = self._get_vm_ipv4(vm) + server['v6_first_ip'] = self._get_vm_ipv6(vm) + + result.append(server) + + return result + + def _populate(self): + hostname_preference = self.get_option('hostname') + group_by_labels = self.get_option('group_by_labels') + strict = self.get_option('strict') + + # Add a top group 'one' + self.inventory.add_group(group='all') + + filter_by_label = self.get_option('filter_by_label') + servers = self._retrieve_servers(filter_by_label) + for server in servers: + hostname = server['name'] + # check for labels + if group_by_labels and server['LABELS']: + for label in server['LABELS']: + self.inventory.add_group(group=label) + self.inventory.add_host(host=hostname, group=label) + + self.inventory.add_host(host=hostname, group='all') + + for attribute, value in server.items(): + self.inventory.set_variable(hostname, attribute, value) + + if hostname_preference != 'name': + self.inventory.set_variable(hostname, 'ansible_host', server[hostname_preference]) + + if server.get('SSH_PORT'): + self.inventory.set_variable(hostname, 'ansible_port', server['SSH_PORT']) + + # handle construcable implementation: get composed variables if any + self._set_composite_vars(self.get_option('compose'), server, hostname, strict=strict) + + # groups based on jinja conditionals get added to specific groups + self._add_host_to_composed_groups(self.get_option('groups'), server, hostname, strict=strict) + + # groups based on variables associated with them in the inventory + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), server, hostname, strict=strict) + + def parse(self, inventory, loader, path, cache=True): + if not HAS_PYONE: + raise AnsibleError('OpenNebula Inventory plugin requires pyone to work!') + + super(InventoryModule, self).parse(inventory, loader, path) + self._read_config_data(path=path) + + self._populate() diff --git a/ansible_collections/community/general/plugins/inventory/proxmox.py b/ansible_collections/community/general/plugins/inventory/proxmox.py new file mode 100644 index 000000000..dc2e1febc --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/proxmox.py @@ -0,0 +1,644 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org>, Daniel Lobato Garcia <dlobatog@redhat.com> +# Copyright (c) 2018 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +DOCUMENTATION = ''' + name: proxmox + short_description: Proxmox inventory source + version_added: "1.2.0" + author: + - Jeffrey van Pelt (@Thulium-Drake) <jeff@vanpelt.one> + requirements: + - requests >= 1.1 + description: + - Get inventory hosts from a Proxmox PVE cluster. + - "Uses a configuration file as an inventory source, it must end in C(.proxmox.yml) or C(.proxmox.yaml)" + - Will retrieve the first network interface with an IP for Proxmox nodes. + - Can retrieve LXC/QEMU configuration as facts. + extends_documentation_fragment: + - constructed + - inventory_cache + options: + plugin: + description: The name of this plugin, it should always be set to C(community.general.proxmox) for this plugin to recognize it as it's own. + required: true + choices: ['community.general.proxmox'] + type: str + url: + description: + - URL to Proxmox cluster. + - If the value is not specified in the inventory configuration, the value of environment variable C(PROXMOX_URL) will be used instead. + - Since community.general 4.7.0 you can also use templating to specify the value of the I(url). + default: 'http://localhost:8006' + type: str + env: + - name: PROXMOX_URL + version_added: 2.0.0 + user: + description: + - Proxmox authentication user. + - If the value is not specified in the inventory configuration, the value of environment variable C(PROXMOX_USER) will be used instead. + - Since community.general 4.7.0 you can also use templating to specify the value of the I(user). + required: true + type: str + env: + - name: PROXMOX_USER + version_added: 2.0.0 + password: + description: + - Proxmox authentication password. + - If the value is not specified in the inventory configuration, the value of environment variable C(PROXMOX_PASSWORD) will be used instead. + - Since community.general 4.7.0 you can also use templating to specify the value of the I(password). + - If you do not specify a password, you must set I(token_id) and I(token_secret) instead. + type: str + env: + - name: PROXMOX_PASSWORD + version_added: 2.0.0 + token_id: + description: + - Proxmox authentication token ID. + - If the value is not specified in the inventory configuration, the value of environment variable C(PROXMOX_TOKEN_ID) will be used instead. + - To use token authentication, you must also specify I(token_secret). If you do not specify I(token_id) and I(token_secret), + you must set a password instead. + - Make sure to grant explicit pve permissions to the token or disable 'privilege separation' to use the users' privileges instead. + version_added: 4.8.0 + type: str + env: + - name: PROXMOX_TOKEN_ID + token_secret: + description: + - Proxmox authentication token secret. + - If the value is not specified in the inventory configuration, the value of environment variable C(PROXMOX_TOKEN_SECRET) will be used instead. + - To use token authentication, you must also specify I(token_id). If you do not specify I(token_id) and I(token_secret), + you must set a password instead. + version_added: 4.8.0 + type: str + env: + - name: PROXMOX_TOKEN_SECRET + validate_certs: + description: Verify SSL certificate if using HTTPS. + type: boolean + default: true + group_prefix: + description: Prefix to apply to Proxmox groups. + default: proxmox_ + type: str + facts_prefix: + description: Prefix to apply to LXC/QEMU config facts. + default: proxmox_ + type: str + want_facts: + description: + - Gather LXC/QEMU configuration facts. + - When I(want_facts) is set to C(true) more details about QEMU VM status are possible, besides the running and stopped states. + Currently if the VM is running and it is suspended, the status will be running and the machine will be in C(running) group, + but its actual state will be paused. See I(qemu_extended_statuses) for how to retrieve the real status. + default: false + type: bool + qemu_extended_statuses: + description: + - Requires I(want_facts) to be set to C(true) to function. This will allow you to differentiate betweend C(paused) and C(prelaunch) + statuses of the QEMU VMs. + - This introduces multiple groups [prefixed with I(group_prefix)] C(prelaunch) and C(paused). + default: false + type: bool + version_added: 5.1.0 + want_proxmox_nodes_ansible_host: + version_added: 3.0.0 + description: + - Whether to set C(ansbile_host) for proxmox nodes. + - When set to C(true) (default), will use the first available interface. This can be different from what you expect. + - The default of this option changed from C(true) to C(false) in community.general 6.0.0. + type: bool + default: false + filters: + version_added: 4.6.0 + description: A list of Jinja templates that allow filtering hosts. + type: list + elements: str + default: [] + strict: + version_added: 2.5.0 + compose: + version_added: 2.5.0 + groups: + version_added: 2.5.0 + keyed_groups: + version_added: 2.5.0 +''' + +EXAMPLES = ''' +# Minimal example which will not gather additional facts for QEMU/LXC guests +# By not specifying a URL the plugin will attempt to connect to the controller host on port 8006 +# my.proxmox.yml +plugin: community.general.proxmox +user: ansible@pve +password: secure +# Note that this can easily give you wrong values as ansible_host. See further below for +# an example where this is set to `false` and where ansible_host is set with `compose`. +want_proxmox_nodes_ansible_host: true + +# Instead of login with password, proxmox supports api token authentication since release 6.2. +plugin: community.general.proxmox +user: ci@pve +token_id: gitlab-1 +token_secret: fa256e9c-26ab-41ec-82da-707a2c079829 + +# The secret can also be a vault string or passed via the environment variable TOKEN_SECRET. +token_secret: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 62353634333163633336343265623632626339313032653563653165313262343931643431656138 + 6134333736323265656466646539663134306166666237630a653363623262636663333762316136 + 34616361326263383766366663393837626437316462313332663736623066656237386531663731 + 3037646432383064630a663165303564623338666131353366373630656661333437393937343331 + 32643131386134396336623736393634373936356332623632306561356361323737313663633633 + 6231313333666361656537343562333337323030623732323833 + +# More complete example demonstrating the use of 'want_facts' and the constructed options +# Note that using facts returned by 'want_facts' in constructed options requires 'want_facts=true' +# my.proxmox.yml +plugin: community.general.proxmox +url: http://pve.domain.com:8006 +user: ansible@pve +password: secure +validate_certs: false +want_facts: true +keyed_groups: + # proxmox_tags_parsed is an example of a fact only returned when 'want_facts=true' + - key: proxmox_tags_parsed + separator: "" + prefix: group +groups: + webservers: "'web' in (proxmox_tags_parsed|list)" + mailservers: "'mail' in (proxmox_tags_parsed|list)" +compose: + ansible_port: 2222 +# Note that this can easily give you wrong values as ansible_host. See further below for +# an example where this is set to `false` and where ansible_host is set with `compose`. +want_proxmox_nodes_ansible_host: true + +# Using the inventory to allow ansible to connect via the first IP address of the VM / Container +# (Default is connection by name of QEMU/LXC guests) +# Note: my_inv_var demonstrates how to add a string variable to every host used by the inventory. +# my.proxmox.yml +plugin: community.general.proxmox +url: http://pve.domain.com:8006 +user: ansible@pve +password: secure +validate_certs: false +want_facts: true +want_proxmox_nodes_ansible_host: false +compose: + ansible_host: proxmox_ipconfig0.ip | default(proxmox_net0.ip) | ipaddr('address') + my_inv_var_1: "'my_var1_value'" + my_inv_var_2: > + "my_var_2_value" + +# Specify the url, user and password using templating +# my.proxmox.yml +plugin: community.general.proxmox +url: "{{ lookup('ansible.builtin.ini', 'url', section='proxmox', file='file.ini') }}" +user: "{{ lookup('ansible.builtin.env','PM_USER') | default('ansible@pve') }}" +password: "{{ lookup('community.general.random_string', base64=True) }}" +# Note that this can easily give you wrong values as ansible_host. See further up for +# an example where this is set to `false` and where ansible_host is set with `compose`. +want_proxmox_nodes_ansible_host: true + +''' + +import itertools +import re + +from ansible.module_utils.common._collections_compat import MutableMapping + +from ansible.errors import AnsibleError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.six import string_types +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.utils.display import Display + +from ansible_collections.community.general.plugins.module_utils.version import LooseVersion + +# 3rd party imports +try: + import requests + if LooseVersion(requests.__version__) < LooseVersion('1.1.0'): + raise ImportError + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +display = Display() + + +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): + ''' Host inventory parser for ansible using Proxmox as source. ''' + + NAME = 'community.general.proxmox' + + def __init__(self): + + super(InventoryModule, self).__init__() + + # from config + self.proxmox_url = None + + self.session = None + self.cache_key = None + self.use_cache = None + + def verify_file(self, path): + + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('proxmox.yaml', 'proxmox.yml')): + valid = True + else: + self.display.vvv('Skipping due to inventory source not ending in "proxmox.yaml" nor "proxmox.yml"') + return valid + + def _get_session(self): + if not self.session: + self.session = requests.session() + self.session.verify = self.get_option('validate_certs') + return self.session + + def _get_auth(self): + credentials = urlencode({'username': self.proxmox_user, 'password': self.proxmox_password, }) + + if self.proxmox_password: + + credentials = urlencode({'username': self.proxmox_user, 'password': self.proxmox_password, }) + + a = self._get_session() + + if a.verify is False: + from requests.packages.urllib3 import disable_warnings + disable_warnings() + + ret = a.post('%s/api2/json/access/ticket' % self.proxmox_url, data=credentials) + + json = ret.json() + + self.headers = { + # only required for POST/PUT/DELETE methods, which we are not using currently + # 'CSRFPreventionToken': json['data']['CSRFPreventionToken'], + 'Cookie': 'PVEAuthCookie={0}'.format(json['data']['ticket']) + } + + else: + + self.headers = {'Authorization': 'PVEAPIToken={0}!{1}={2}'.format(self.proxmox_user, self.proxmox_token_id, self.proxmox_token_secret)} + + def _get_json(self, url, ignore_errors=None): + + if not self.use_cache or url not in self._cache.get(self.cache_key, {}): + + if self.cache_key not in self._cache: + self._cache[self.cache_key] = {'url': ''} + + data = [] + s = self._get_session() + while True: + ret = s.get(url, headers=self.headers) + if ignore_errors and ret.status_code in ignore_errors: + break + ret.raise_for_status() + json = ret.json() + + # process results + # FIXME: This assumes 'return type' matches a specific query, + # it will break if we expand the queries and they dont have different types + if 'data' not in json: + # /hosts/:id does not have a 'data' key + data = json + break + elif isinstance(json['data'], MutableMapping): + # /facts are returned as dict in 'data' + data = json['data'] + break + else: + # /hosts 's 'results' is a list of all hosts, returned is paginated + data = data + json['data'] + break + + self._cache[self.cache_key][url] = data + + return self._cache[self.cache_key][url] + + def _get_nodes(self): + return self._get_json("%s/api2/json/nodes" % self.proxmox_url) + + def _get_pools(self): + return self._get_json("%s/api2/json/pools" % self.proxmox_url) + + def _get_lxc_per_node(self, node): + return self._get_json("%s/api2/json/nodes/%s/lxc" % (self.proxmox_url, node)) + + def _get_qemu_per_node(self, node): + return self._get_json("%s/api2/json/nodes/%s/qemu" % (self.proxmox_url, node)) + + def _get_members_per_pool(self, pool): + ret = self._get_json("%s/api2/json/pools/%s" % (self.proxmox_url, pool)) + return ret['members'] + + def _get_node_ip(self, node): + ret = self._get_json("%s/api2/json/nodes/%s/network" % (self.proxmox_url, node)) + + for iface in ret: + try: + return iface['address'] + except Exception: + return None + + def _get_agent_network_interfaces(self, node, vmid, vmtype): + result = [] + + try: + ifaces = self._get_json( + "%s/api2/json/nodes/%s/%s/%s/agent/network-get-interfaces" % ( + self.proxmox_url, node, vmtype, vmid + ) + )['result'] + + if "error" in ifaces: + if "class" in ifaces["error"]: + # This happens on Windows, even though qemu agent is running, the IP address + # cannot be fetched, as it's unsupported, also a command disabled can happen. + errorClass = ifaces["error"]["class"] + if errorClass in ["Unsupported"]: + self.display.v("Retrieving network interfaces from guest agents on windows with older qemu-guest-agents is not supported") + elif errorClass in ["CommandDisabled"]: + self.display.v("Retrieving network interfaces from guest agents has been disabled") + return result + + for iface in ifaces: + result.append({ + 'name': iface['name'], + 'mac-address': iface['hardware-address'] if 'hardware-address' in iface else '', + 'ip-addresses': ["%s/%s" % (ip['ip-address'], ip['prefix']) for ip in iface['ip-addresses']] if 'ip-addresses' in iface else [] + }) + except requests.HTTPError: + pass + + return result + + def _get_vm_config(self, properties, node, vmid, vmtype, name): + ret = self._get_json("%s/api2/json/nodes/%s/%s/%s/config" % (self.proxmox_url, node, vmtype, vmid)) + + properties[self._fact('node')] = node + properties[self._fact('vmid')] = vmid + properties[self._fact('vmtype')] = vmtype + + plaintext_configs = [ + 'description', + ] + + for config in ret: + key = self._fact(config) + value = ret[config] + try: + # fixup disk images as they have no key + if config == 'rootfs' or config.startswith(('virtio', 'sata', 'ide', 'scsi')): + value = ('disk_image=' + value) + + # Additional field containing parsed tags as list + if config == 'tags': + stripped_value = value.strip() + if stripped_value: + parsed_key = key + "_parsed" + properties[parsed_key] = [tag.strip() for tag in stripped_value.replace(',', ';').split(";")] + + # The first field in the agent string tells you whether the agent is enabled + # the rest of the comma separated string is extra config for the agent. + # In some (newer versions of proxmox) instances it can be 'enabled=1'. + if config == 'agent': + agent_enabled = 0 + try: + agent_enabled = int(value.split(',')[0]) + except ValueError: + if value.split(',')[0] == "enabled=1": + agent_enabled = 1 + if agent_enabled: + agent_iface_value = self._get_agent_network_interfaces(node, vmid, vmtype) + if agent_iface_value: + agent_iface_key = self.to_safe('%s%s' % (key, "_interfaces")) + properties[agent_iface_key] = agent_iface_value + + if config == 'lxc': + out_val = {} + for k, v in value: + if k.startswith('lxc.'): + k = k[len('lxc.'):] + out_val[k] = v + value = out_val + + if config not in plaintext_configs and isinstance(value, string_types) \ + and all("=" in v for v in value.split(",")): + # split off strings with commas to a dict + # skip over any keys that cannot be processed + try: + value = dict(key.split("=", 1) for key in value.split(",")) + except Exception: + continue + + properties[key] = value + except NameError: + return None + + def _get_vm_status(self, properties, node, vmid, vmtype, name): + ret = self._get_json("%s/api2/json/nodes/%s/%s/%s/status/current" % (self.proxmox_url, node, vmtype, vmid)) + properties[self._fact('status')] = ret['status'] + if vmtype == 'qemu': + properties[self._fact('qmpstatus')] = ret['qmpstatus'] + + def _get_vm_snapshots(self, properties, node, vmid, vmtype, name): + ret = self._get_json("%s/api2/json/nodes/%s/%s/%s/snapshot" % (self.proxmox_url, node, vmtype, vmid)) + snapshots = [snapshot['name'] for snapshot in ret if snapshot['name'] != 'current'] + properties[self._fact('snapshots')] = snapshots + + def to_safe(self, word): + '''Converts 'bad' characters in a string to underscores so they can be used as Ansible groups + #> ProxmoxInventory.to_safe("foo-bar baz") + 'foo_barbaz' + ''' + regex = r"[^A-Za-z0-9\_]" + return re.sub(regex, "_", word.replace(" ", "")) + + def _fact(self, name): + '''Generate a fact's full name from the common prefix and a name.''' + return self.to_safe('%s%s' % (self.facts_prefix, name.lower())) + + def _group(self, name): + '''Generate a group's full name from the common prefix and a name.''' + return self.to_safe('%s%s' % (self.group_prefix, name.lower())) + + def _can_add_host(self, name, properties): + '''Ensure that a host satisfies all defined hosts filters. If strict mode is + enabled, any error during host filter compositing will lead to an AnsibleError + being raised, otherwise the filter will be ignored. + ''' + for host_filter in self.host_filters: + try: + if not self._compose(host_filter, properties): + return False + except Exception as e: # pylint: disable=broad-except + message = "Could not evaluate host filter %s for host %s - %s" % (host_filter, name, to_native(e)) + if self.strict: + raise AnsibleError(message) + display.warning(message) + return True + + def _add_host(self, name, variables): + self.inventory.add_host(name) + for k, v in variables.items(): + self.inventory.set_variable(name, k, v) + variables = self.inventory.get_host(name).get_vars() + self._set_composite_vars(self.get_option('compose'), variables, name, strict=self.strict) + self._add_host_to_composed_groups(self.get_option('groups'), variables, name, strict=self.strict) + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), variables, name, strict=self.strict) + + def _handle_item(self, node, ittype, item): + '''Handle an item from the list of LXC containers and Qemu VM. The + return value will be either None if the item was skipped or the name of + the item if it was added to the inventory.''' + if item.get('template'): + return None + + properties = dict() + name, vmid = item['name'], item['vmid'] + + # get status, config and snapshots if want_facts == True + want_facts = self.get_option('want_facts') + if want_facts: + self._get_vm_status(properties, node, vmid, ittype, name) + self._get_vm_config(properties, node, vmid, ittype, name) + self._get_vm_snapshots(properties, node, vmid, ittype, name) + + # ensure the host satisfies filters + if not self._can_add_host(name, properties): + return None + + # add the host to the inventory + self._add_host(name, properties) + node_type_group = self._group('%s_%s' % (node, ittype)) + self.inventory.add_child(self._group('all_' + ittype), name) + self.inventory.add_child(node_type_group, name) + + item_status = item['status'] + if item_status == 'running': + if want_facts and ittype == 'qemu' and self.get_option('qemu_extended_statuses'): + # get more details about the status of the qemu VM + item_status = properties.get(self._fact('qmpstatus'), item_status) + self.inventory.add_child(self._group('all_%s' % (item_status, )), name) + + return name + + def _populate_pool_groups(self, added_hosts): + '''Generate groups from Proxmox resource pools, ignoring VMs and + containers that were skipped.''' + for pool in self._get_pools(): + poolid = pool.get('poolid') + if not poolid: + continue + pool_group = self._group('pool_' + poolid) + self.inventory.add_group(pool_group) + + for member in self._get_members_per_pool(poolid): + name = member.get('name') + if name and name in added_hosts: + self.inventory.add_child(pool_group, name) + + def _populate(self): + + # create common groups + default_groups = ['lxc', 'qemu', 'running', 'stopped'] + + if self.get_option('qemu_extended_statuses'): + default_groups.extend(['prelaunch', 'paused']) + + for group in default_groups: + self.inventory.add_group(self._group('all_%s' % (group))) + + nodes_group = self._group('nodes') + self.inventory.add_group(nodes_group) + + want_proxmox_nodes_ansible_host = self.get_option("want_proxmox_nodes_ansible_host") + + # gather vm's on nodes + self._get_auth() + hosts = [] + for node in self._get_nodes(): + if not node.get('node'): + continue + + self.inventory.add_host(node['node']) + if node['type'] == 'node': + self.inventory.add_child(nodes_group, node['node']) + + if node['status'] == 'offline': + continue + + # get node IP address + if want_proxmox_nodes_ansible_host: + ip = self._get_node_ip(node['node']) + self.inventory.set_variable(node['node'], 'ansible_host', ip) + + # add LXC/Qemu groups for the node + for ittype in ('lxc', 'qemu'): + node_type_group = self._group('%s_%s' % (node['node'], ittype)) + self.inventory.add_group(node_type_group) + + # get LXC containers and Qemu VMs for this node + lxc_objects = zip(itertools.repeat('lxc'), self._get_lxc_per_node(node['node'])) + qemu_objects = zip(itertools.repeat('qemu'), self._get_qemu_per_node(node['node'])) + for ittype, item in itertools.chain(lxc_objects, qemu_objects): + name = self._handle_item(node['node'], ittype, item) + if name is not None: + hosts.append(name) + + # gather vm's in pools + self._populate_pool_groups(hosts) + + def parse(self, inventory, loader, path, cache=True): + if not HAS_REQUESTS: + raise AnsibleError('This module requires Python Requests 1.1.0 or higher: ' + 'https://github.com/psf/requests.') + + super(InventoryModule, self).parse(inventory, loader, path) + + # read config from file, this sets 'options' + self._read_config_data(path) + + # read and template auth options + for o in ('url', 'user', 'password', 'token_id', 'token_secret'): + v = self.get_option(o) + if self.templar.is_template(v): + v = self.templar.template(v, disable_lookups=False) + setattr(self, 'proxmox_%s' % o, v) + + # some more cleanup and validation + self.proxmox_url = self.proxmox_url.rstrip('/') + + if self.proxmox_password is None and (self.proxmox_token_id is None or self.proxmox_token_secret is None): + raise AnsibleError('You must specify either a password or both token_id and token_secret.') + + if self.get_option('qemu_extended_statuses') and not self.get_option('want_facts'): + raise AnsibleError('You must set want_facts to True if you want to use qemu_extended_statuses.') + + # read rest of options + self.cache_key = self.get_cache_key(path) + self.use_cache = cache and self.get_option('cache') + self.host_filters = self.get_option('filters') + self.group_prefix = self.get_option('group_prefix') + self.facts_prefix = self.get_option('facts_prefix') + self.strict = self.get_option('strict') + + # actually populate inventory + self._populate() diff --git a/ansible_collections/community/general/plugins/inventory/scaleway.py b/ansible_collections/community/general/plugins/inventory/scaleway.py new file mode 100644 index 000000000..6aacc9f66 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/scaleway.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + +DOCUMENTATION = r''' + name: scaleway + author: + - Remy Leone (@remyleone) + short_description: Scaleway inventory source + description: + - Get inventory hosts from Scaleway. + requirements: + - PyYAML + options: + plugin: + description: Token that ensures this is a source file for the 'scaleway' plugin. + required: true + choices: ['scaleway', 'community.general.scaleway'] + regions: + description: Filter results on a specific Scaleway region. + type: list + elements: string + default: + - ams1 + - par1 + - par2 + - waw1 + tags: + description: Filter results on a specific tag. + type: list + elements: string + scw_profile: + description: + - The config profile to use in config file. + - By default uses the one specified as C(active_profile) in the config file, or falls back to C(default) if that is not defined. + type: string + version_added: 4.4.0 + oauth_token: + description: + - Scaleway OAuth token. + - If not explicitly defined or in environment variables, it will try to lookup in the scaleway-cli configuration file + (C($SCW_CONFIG_PATH), C($XDG_CONFIG_HOME/scw/config.yaml), or C(~/.config/scw/config.yaml)). + - More details on L(how to generate token, https://www.scaleway.com/en/docs/generate-api-keys/). + env: + # in order of precedence + - name: SCW_TOKEN + - name: SCW_API_KEY + - name: SCW_OAUTH_TOKEN + hostnames: + description: List of preference about what to use as an hostname. + type: list + elements: string + default: + - public_ipv4 + choices: + - public_ipv4 + - private_ipv4 + - public_ipv6 + - hostname + - id + variables: + description: 'Set individual variables: keys are variable names and + values are templates. Any value returned by the + L(Scaleway API, https://developer.scaleway.com/#servers-server-get) + can be used.' + type: dict +''' + +EXAMPLES = r''' +# scaleway_inventory.yml file in YAML format +# Example command line: ansible-inventory --list -i scaleway_inventory.yml + +# use hostname as inventory_hostname +# use the private IP address to connect to the host +plugin: community.general.scaleway +regions: + - ams1 + - par1 +tags: + - foobar +hostnames: + - hostname +variables: + ansible_host: private_ip + state: state + +# use hostname as inventory_hostname and public IP address to connect to the host +plugin: community.general.scaleway +hostnames: + - hostname +regions: + - par1 +variables: + ansible_host: public_ip.address + +# Using static strings as variables +plugin: community.general.scaleway +hostnames: + - hostname +variables: + ansible_host: public_ip.address + ansible_connection: "'ssh'" + ansible_user: "'admin'" +''' + +import os +import json + +try: + import yaml +except ImportError as exc: + YAML_IMPORT_ERROR = exc +else: + YAML_IMPORT_ERROR = None + +from ansible.errors import AnsibleError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable +from ansible_collections.community.general.plugins.module_utils.scaleway import SCALEWAY_LOCATION, parse_pagination_link +from ansible.module_utils.urls import open_url +from ansible.module_utils.common.text.converters import to_native, to_text +from ansible.module_utils.six import raise_from + +import ansible.module_utils.six.moves.urllib.parse as urllib_parse + + +def _fetch_information(token, url): + results = [] + paginated_url = url + while True: + try: + response = open_url(paginated_url, + headers={'X-Auth-Token': token, + 'Content-type': 'application/json'}) + except Exception as e: + raise AnsibleError("Error while fetching %s: %s" % (url, to_native(e))) + try: + raw_json = json.loads(to_text(response.read())) + except ValueError: + raise AnsibleError("Incorrect JSON payload") + + try: + results.extend(raw_json["servers"]) + except KeyError: + raise AnsibleError("Incorrect format from the Scaleway API response") + + link = response.headers['Link'] + if not link: + return results + relations = parse_pagination_link(link) + if 'next' not in relations: + return results + paginated_url = urllib_parse.urljoin(paginated_url, relations['next']) + + +def _build_server_url(api_endpoint): + return "/".join([api_endpoint, "servers"]) + + +def extract_public_ipv4(server_info): + try: + return server_info["public_ip"]["address"] + except (KeyError, TypeError): + return None + + +def extract_private_ipv4(server_info): + try: + return server_info["private_ip"] + except (KeyError, TypeError): + return None + + +def extract_hostname(server_info): + try: + return server_info["hostname"] + except (KeyError, TypeError): + return None + + +def extract_server_id(server_info): + try: + return server_info["id"] + except (KeyError, TypeError): + return None + + +def extract_public_ipv6(server_info): + try: + return server_info["ipv6"]["address"] + except (KeyError, TypeError): + return None + + +def extract_tags(server_info): + try: + return server_info["tags"] + except (KeyError, TypeError): + return None + + +def extract_zone(server_info): + try: + return server_info["location"]["zone_id"] + except (KeyError, TypeError): + return None + + +extractors = { + "public_ipv4": extract_public_ipv4, + "private_ipv4": extract_private_ipv4, + "public_ipv6": extract_public_ipv6, + "hostname": extract_hostname, + "id": extract_server_id +} + + +class InventoryModule(BaseInventoryPlugin, Constructable): + NAME = 'community.general.scaleway' + + def _fill_host_variables(self, host, server_info): + targeted_attributes = ( + "arch", + "commercial_type", + "id", + "organization", + "state", + "hostname", + ) + for attribute in targeted_attributes: + self.inventory.set_variable(host, attribute, server_info[attribute]) + + self.inventory.set_variable(host, "tags", server_info["tags"]) + + if extract_public_ipv6(server_info=server_info): + self.inventory.set_variable(host, "public_ipv6", extract_public_ipv6(server_info=server_info)) + + if extract_public_ipv4(server_info=server_info): + self.inventory.set_variable(host, "public_ipv4", extract_public_ipv4(server_info=server_info)) + + if extract_private_ipv4(server_info=server_info): + self.inventory.set_variable(host, "private_ipv4", extract_private_ipv4(server_info=server_info)) + + def _get_zones(self, config_zones): + return set(SCALEWAY_LOCATION.keys()).intersection(config_zones) + + def match_groups(self, server_info, tags): + server_zone = extract_zone(server_info=server_info) + server_tags = extract_tags(server_info=server_info) + + # If a server does not have a zone, it means it is archived + if server_zone is None: + return set() + + # If no filtering is defined, all tags are valid groups + if tags is None: + return set(server_tags).union((server_zone,)) + + matching_tags = set(server_tags).intersection(tags) + + if not matching_tags: + return set() + return matching_tags.union((server_zone,)) + + def _filter_host(self, host_infos, hostname_preferences): + + for pref in hostname_preferences: + if extractors[pref](host_infos): + return extractors[pref](host_infos) + + return None + + def do_zone_inventory(self, zone, token, tags, hostname_preferences): + self.inventory.add_group(zone) + zone_info = SCALEWAY_LOCATION[zone] + + url = _build_server_url(zone_info["api_endpoint"]) + raw_zone_hosts_infos = _fetch_information(url=url, token=token) + + for host_infos in raw_zone_hosts_infos: + + hostname = self._filter_host(host_infos=host_infos, + hostname_preferences=hostname_preferences) + + # No suitable hostname were found in the attributes and the host won't be in the inventory + if not hostname: + continue + + groups = self.match_groups(host_infos, tags) + + for group in groups: + self.inventory.add_group(group=group) + self.inventory.add_host(group=group, host=hostname) + self._fill_host_variables(host=hostname, server_info=host_infos) + + # Composed variables + self._set_composite_vars(self.get_option('variables'), host_infos, hostname, strict=False) + + def get_oauth_token(self): + oauth_token = self.get_option('oauth_token') + + if 'SCW_CONFIG_PATH' in os.environ: + scw_config_path = os.getenv('SCW_CONFIG_PATH') + elif 'XDG_CONFIG_HOME' in os.environ: + scw_config_path = os.path.join(os.getenv('XDG_CONFIG_HOME'), 'scw', 'config.yaml') + else: + scw_config_path = os.path.join(os.path.expanduser('~'), '.config', 'scw', 'config.yaml') + + if not oauth_token and os.path.exists(scw_config_path): + with open(scw_config_path) as fh: + scw_config = yaml.safe_load(fh) + ansible_profile = self.get_option('scw_profile') + + if ansible_profile: + active_profile = ansible_profile + else: + active_profile = scw_config.get('active_profile', 'default') + + if active_profile == 'default': + oauth_token = scw_config.get('secret_key') + else: + oauth_token = scw_config['profiles'][active_profile].get('secret_key') + + return oauth_token + + def parse(self, inventory, loader, path, cache=True): + if YAML_IMPORT_ERROR: + raise_from(AnsibleError('PyYAML is probably missing'), YAML_IMPORT_ERROR) + super(InventoryModule, self).parse(inventory, loader, path) + self._read_config_data(path=path) + + config_zones = self.get_option("regions") + tags = self.get_option("tags") + token = self.get_oauth_token() + if not token: + raise AnsibleError("'oauth_token' value is null, you must configure it either in inventory, envvars or scaleway-cli config.") + hostname_preference = self.get_option("hostnames") + + for zone in self._get_zones(config_zones): + self.do_zone_inventory(zone=zone, token=token, tags=tags, hostname_preferences=hostname_preference) diff --git a/ansible_collections/community/general/plugins/inventory/stackpath_compute.py b/ansible_collections/community/general/plugins/inventory/stackpath_compute.py new file mode 100644 index 000000000..39f880e82 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/stackpath_compute.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020 Shay Rybak <shay.rybak@stackpath.com> +# Copyright (c) 2020 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: stackpath_compute + short_description: StackPath Edge Computing inventory source + version_added: 1.2.0 + author: + - UNKNOWN (@shayrybak) + extends_documentation_fragment: + - inventory_cache + - constructed + description: + - Get inventory hosts from StackPath Edge Computing. + - Uses a YAML configuration file that ends with stackpath_compute.(yml|yaml). + options: + plugin: + description: + - A token that ensures this is a source file for the plugin. + required: true + choices: ['community.general.stackpath_compute'] + client_id: + description: + - An OAuth client ID generated from the API Management section of the StackPath customer portal + U(https://control.stackpath.net/api-management). + required: true + type: str + client_secret: + description: + - An OAuth client secret generated from the API Management section of the StackPath customer portal + U(https://control.stackpath.net/api-management). + required: true + type: str + stack_slugs: + description: + - A list of Stack slugs to query instances in. If no entry then get instances in all stacks on the account. + type: list + elements: str + use_internal_ip: + description: + - Whether or not to use internal IP addresses, If false, uses external IP addresses, internal otherwise. + - If an instance doesn't have an external IP it will not be returned when this option is set to false. + type: bool +''' + +EXAMPLES = ''' +# Example using credentials to fetch all workload instances in a stack. +--- +plugin: community.general.stackpath_compute +client_id: my_client_id +client_secret: my_client_secret +stack_slugs: +- my_first_stack_slug +- my_other_stack_slug +use_internal_ip: false +''' + +import traceback +import json + +from ansible.errors import AnsibleError +from ansible.module_utils.urls import open_url +from ansible.plugins.inventory import ( + BaseInventoryPlugin, + Constructable, + Cacheable +) +from ansible.utils.display import Display + + +display = Display() + + +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): + + NAME = 'community.general.stackpath_compute' + + def __init__(self): + super(InventoryModule, self).__init__() + + # credentials + self.client_id = None + self.client_secret = None + self.stack_slug = None + self.api_host = "https://gateway.stackpath.com" + self.group_keys = [ + "stackSlug", + "workloadId", + "cityCode", + "countryCode", + "continent", + "target", + "name", + "workloadSlug" + ] + + def _validate_config(self, config): + if config['plugin'] != 'community.general.stackpath_compute': + raise AnsibleError("plugin doesn't match this plugin") + try: + client_id = config['client_id'] + if len(client_id) != 32: + raise AnsibleError("client_id must be 32 characters long") + except KeyError: + raise AnsibleError("config missing client_id, a required option") + try: + client_secret = config['client_secret'] + if len(client_secret) != 64: + raise AnsibleError("client_secret must be 64 characters long") + except KeyError: + raise AnsibleError("config missing client_id, a required option") + return True + + def _set_credentials(self): + ''' + :param config_data: contents of the inventory config file + ''' + self.client_id = self.get_option('client_id') + self.client_secret = self.get_option('client_secret') + + def _authenticate(self): + payload = json.dumps( + { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "client_credentials", + } + ) + headers = { + "Content-Type": "application/json", + } + resp = open_url( + self.api_host + '/identity/v1/oauth2/token', + headers=headers, + data=payload, + method="POST" + ) + status_code = resp.code + if status_code == 200: + body = resp.read() + self.auth_token = json.loads(body)["access_token"] + + def _query(self): + results = [] + workloads = [] + self._authenticate() + for stack_slug in self.stack_slugs: + try: + workloads = self._stackpath_query_get_list(self.api_host + '/workload/v1/stacks/' + stack_slug + '/workloads') + except Exception: + raise AnsibleError("Failed to get workloads from the StackPath API: %s" % traceback.format_exc()) + for workload in workloads: + try: + workload_instances = self._stackpath_query_get_list( + self.api_host + '/workload/v1/stacks/' + stack_slug + '/workloads/' + workload["id"] + '/instances' + ) + except Exception: + raise AnsibleError("Failed to get workload instances from the StackPath API: %s" % traceback.format_exc()) + for instance in workload_instances: + if instance["phase"] == "RUNNING": + instance["stackSlug"] = stack_slug + instance["workloadId"] = workload["id"] + instance["workloadSlug"] = workload["slug"] + instance["cityCode"] = instance["location"]["cityCode"] + instance["countryCode"] = instance["location"]["countryCode"] + instance["continent"] = instance["location"]["continent"] + instance["target"] = instance["metadata"]["labels"]["workload.platform.stackpath.net/target-name"] + try: + if instance[self.hostname_key]: + results.append(instance) + except KeyError: + pass + return results + + def _populate(self, instances): + for instance in instances: + for group_key in self.group_keys: + group = group_key + "_" + instance[group_key] + group = group.lower().replace(" ", "_").replace("-", "_") + self.inventory.add_group(group) + self.inventory.add_host(instance[self.hostname_key], + group=group) + + def _stackpath_query_get_list(self, url): + self._authenticate() + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer " + self.auth_token, + } + next_page = True + result = [] + cursor = '-1' + while next_page: + resp = open_url( + url + '?page_request.first=10&page_request.after=%s' % cursor, + headers=headers, + method="GET" + ) + status_code = resp.code + if status_code == 200: + body = resp.read() + body_json = json.loads(body) + result.extend(body_json["results"]) + next_page = body_json["pageInfo"]["hasNextPage"] + if next_page: + cursor = body_json["pageInfo"]["endCursor"] + return result + + def _get_stack_slugs(self, stacks): + self.stack_slugs = [stack["slug"] for stack in stacks] + + def verify_file(self, path): + ''' + :param loader: an ansible.parsing.dataloader.DataLoader object + :param path: the path to the inventory config file + :return the contents of the config file + ''' + if super(InventoryModule, self).verify_file(path): + if path.endswith(('stackpath_compute.yml', 'stackpath_compute.yaml')): + return True + display.debug( + "stackpath_compute inventory filename must end with \ + 'stackpath_compute.yml' or 'stackpath_compute.yaml'" + ) + return False + + def parse(self, inventory, loader, path, cache=True): + + super(InventoryModule, self).parse(inventory, loader, path) + + config = self._read_config_data(path) + self._validate_config(config) + self._set_credentials() + + # get user specifications + self.use_internal_ip = self.get_option('use_internal_ip') + if self.use_internal_ip: + self.hostname_key = "ipAddress" + else: + self.hostname_key = "externalIpAddress" + + self.stack_slugs = self.get_option('stack_slugs') + if not self.stack_slugs: + try: + stacks = self._stackpath_query_get_list(self.api_host + '/stack/v1/stacks') + self._get_stack_slugs(stacks) + except Exception: + raise AnsibleError("Failed to get stack IDs from the Stackpath API: %s" % traceback.format_exc()) + + cache_key = self.get_cache_key(path) + # false when refresh_cache or --flush-cache is used + if cache: + # get the user-specified directive + cache = self.get_option('cache') + + # Generate inventory + cache_needs_update = False + if cache: + try: + results = self._cache[cache_key] + except KeyError: + # if cache expires or cache file doesn't exist + cache_needs_update = True + + if not cache or cache_needs_update: + results = self._query() + + self._populate(results) + + # If the cache has expired/doesn't exist or + # if refresh_inventory/flush cache is used + # when the user is using caching, update the cached inventory + try: + if cache_needs_update or (not cache and self.get_option('cache')): + self._cache[cache_key] = results + except Exception: + raise AnsibleError("Failed to populate data: %s" % traceback.format_exc()) diff --git a/ansible_collections/community/general/plugins/inventory/virtualbox.py b/ansible_collections/community/general/plugins/inventory/virtualbox.py new file mode 100644 index 000000000..c926d8b44 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/virtualbox.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + author: Unknown (!UNKNOWN) + name: virtualbox + short_description: virtualbox inventory source + description: + - Get inventory hosts from the local virtualbox installation. + - Uses a YAML configuration file that ends with virtualbox.(yml|yaml) or vbox.(yml|yaml). + - The inventory_hostname is always the 'Name' of the virtualbox instance. + extends_documentation_fragment: + - constructed + - inventory_cache + options: + plugin: + description: token that ensures this is a source file for the 'virtualbox' plugin + required: true + choices: ['virtualbox', 'community.general.virtualbox'] + running_only: + description: toggles showing all vms vs only those currently running + type: boolean + default: false + settings_password_file: + description: provide a file containing the settings password (equivalent to --settingspwfile) + network_info_path: + description: property path to query for network information (ansible_host) + default: "/VirtualBox/GuestInfo/Net/0/V4/IP" + query: + description: create vars from virtualbox properties + type: dictionary + default: {} +''' + +EXAMPLES = ''' +# file must be named vbox.yaml or vbox.yml +simple_config_file: + plugin: community.general.virtualbox + settings_password_file: /etc/virtulbox/secrets + query: + logged_in_users: /VirtualBox/GuestInfo/OS/LoggedInUsersList + compose: + ansible_connection: ('indows' in vbox_Guest_OS)|ternary('winrm', 'ssh') + +# add hosts (all match with minishift vm) to the group container if any of the vms are in ansible_inventory' +plugin: community.general.virtualbox +groups: + container: "'minis' in (inventory_hostname)" +''' + +import os + +from subprocess import Popen, PIPE + +from ansible.errors import AnsibleParserError +from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text +from ansible.module_utils.common._collections_compat import MutableMapping +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable +from ansible.module_utils.common.process import get_bin_path + + +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): + ''' Host inventory parser for ansible using local virtualbox. ''' + + NAME = 'community.general.virtualbox' + VBOX = "VBoxManage" + + def __init__(self): + self._vbox_path = None + super(InventoryModule, self).__init__() + + def _query_vbox_data(self, host, property_path): + ret = None + try: + cmd = [self._vbox_path, b'guestproperty', b'get', + to_bytes(host, errors='surrogate_or_strict'), + to_bytes(property_path, errors='surrogate_or_strict')] + x = Popen(cmd, stdout=PIPE) + ipinfo = to_text(x.stdout.read(), errors='surrogate_or_strict') + if 'Value' in ipinfo: + a, ip = ipinfo.split(':', 1) + ret = ip.strip() + except Exception: + pass + return ret + + def _set_variables(self, hostvars): + + # set vars in inventory from hostvars + for host in hostvars: + + query = self.get_option('query') + # create vars from vbox properties + if query and isinstance(query, MutableMapping): + for varname in query: + hostvars[host][varname] = self._query_vbox_data(host, query[varname]) + + strict = self.get_option('strict') + + # create composite vars + self._set_composite_vars(self.get_option('compose'), hostvars[host], host, strict=strict) + + # actually update inventory + for key in hostvars[host]: + self.inventory.set_variable(host, key, hostvars[host][key]) + + # constructed groups based on conditionals + self._add_host_to_composed_groups(self.get_option('groups'), hostvars[host], host, strict=strict) + + # constructed keyed_groups + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), hostvars[host], host, strict=strict) + + def _populate_from_cache(self, source_data): + hostvars = source_data.pop('_meta', {}).get('hostvars', {}) + for group in source_data: + if group == 'all': + continue + else: + group = self.inventory.add_group(group) + hosts = source_data[group].get('hosts', []) + for host in hosts: + self._populate_host_vars([host], hostvars.get(host, {}), group) + self.inventory.add_child('all', group) + if not source_data: + for host in hostvars: + self.inventory.add_host(host) + self._populate_host_vars([host], hostvars.get(host, {})) + + def _populate_from_source(self, source_data, using_current_cache=False): + if using_current_cache: + self._populate_from_cache(source_data) + return source_data + + cacheable_results = {'_meta': {'hostvars': {}}} + + hostvars = {} + prevkey = pref_k = '' + current_host = None + + # needed to possibly set ansible_host + netinfo = self.get_option('network_info_path') + + for line in source_data: + line = to_text(line) + if ':' not in line: + continue + try: + k, v = line.split(':', 1) + except Exception: + # skip non splitable + continue + + if k.strip() == '': + # skip empty + continue + + v = v.strip() + # found host + if k.startswith('Name') and ',' not in v: # some setting strings appear in Name + current_host = v + if current_host not in hostvars: + hostvars[current_host] = {} + self.inventory.add_host(current_host) + + # try to get network info + netdata = self._query_vbox_data(current_host, netinfo) + if netdata: + self.inventory.set_variable(current_host, 'ansible_host', netdata) + + # found groups + elif k == 'Groups': + for group in v.split('/'): + if group: + group = self.inventory.add_group(group) + self.inventory.add_child(group, current_host) + if group not in cacheable_results: + cacheable_results[group] = {'hosts': []} + cacheable_results[group]['hosts'].append(current_host) + continue + + else: + # found vars, accumulate in hostvars for clean inventory set + pref_k = 'vbox_' + k.strip().replace(' ', '_') + leading_spaces = len(k) - len(k.lstrip(' ')) + if 0 < leading_spaces <= 2: + if prevkey not in hostvars[current_host] or not isinstance(hostvars[current_host][prevkey], dict): + hostvars[current_host][prevkey] = {} + hostvars[current_host][prevkey][pref_k] = v + elif leading_spaces > 2: + continue + else: + if v != '': + hostvars[current_host][pref_k] = v + if self._ungrouped_host(current_host, cacheable_results): + if 'ungrouped' not in cacheable_results: + cacheable_results['ungrouped'] = {'hosts': []} + cacheable_results['ungrouped']['hosts'].append(current_host) + + prevkey = pref_k + + self._set_variables(hostvars) + for host in hostvars: + h = self.inventory.get_host(host) + cacheable_results['_meta']['hostvars'][h.name] = h.vars + + return cacheable_results + + def _ungrouped_host(self, host, inventory): + def find_host(host, inventory): + for k, v in inventory.items(): + if k == '_meta': + continue + if isinstance(v, dict): + yield self._ungrouped_host(host, v) + elif isinstance(v, list): + yield host not in v + yield True + + return all(find_host(host, inventory)) + + def verify_file(self, path): + + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('virtualbox.yaml', 'virtualbox.yml', 'vbox.yaml', 'vbox.yml')): + valid = True + return valid + + def parse(self, inventory, loader, path, cache=True): + + try: + self._vbox_path = get_bin_path(self.VBOX) + except ValueError as e: + raise AnsibleParserError(e) + + super(InventoryModule, self).parse(inventory, loader, path) + + cache_key = self.get_cache_key(path) + + config_data = self._read_config_data(path) + + # set _options from config data + self._consume_options(config_data) + + source_data = None + if cache: + cache = self.get_option('cache') + + update_cache = False + if cache: + try: + source_data = self._cache[cache_key] + except KeyError: + update_cache = True + + if not source_data: + b_pwfile = to_bytes(self.get_option('settings_password_file'), errors='surrogate_or_strict', nonstring='passthru') + running = self.get_option('running_only') + + # start getting data + cmd = [self._vbox_path, b'list', b'-l'] + if running: + cmd.append(b'runningvms') + else: + cmd.append(b'vms') + + if b_pwfile and os.path.exists(b_pwfile): + cmd.append(b'--settingspwfile') + cmd.append(b_pwfile) + + try: + p = Popen(cmd, stdout=PIPE) + except Exception as e: + raise AnsibleParserError(to_native(e)) + + source_data = p.stdout.read().splitlines() + + using_current_cache = cache and not update_cache + cacheable_results = self._populate_from_source(source_data, using_current_cache) + + if update_cache: + self._cache[cache_key] = cacheable_results diff --git a/ansible_collections/community/general/plugins/inventory/xen_orchestra.py b/ansible_collections/community/general/plugins/inventory/xen_orchestra.py new file mode 100644 index 000000000..ddbdd9bb0 --- /dev/null +++ b/ansible_collections/community/general/plugins/inventory/xen_orchestra.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: xen_orchestra + short_description: Xen Orchestra inventory source + version_added: 4.1.0 + author: + - Dom Del Nano (@ddelnano) <ddelnano@gmail.com> + - Samori Gorse (@shinuza) <samorigorse@gmail.com> + requirements: + - websocket-client >= 1.0.0 + description: + - Get inventory hosts from a Xen Orchestra deployment. + - 'Uses a configuration file as an inventory source, it must end in C(.xen_orchestra.yml) or C(.xen_orchestra.yaml).' + extends_documentation_fragment: + - constructed + - inventory_cache + options: + plugin: + description: The name of this plugin, it should always be set to C(community.general.xen_orchestra) for this plugin to recognize it as its own. + required: true + choices: ['community.general.xen_orchestra'] + type: str + api_host: + description: + - API host to XOA API. + - If the value is not specified in the inventory configuration, the value of environment variable C(ANSIBLE_XO_HOST) will be used instead. + type: str + env: + - name: ANSIBLE_XO_HOST + user: + description: + - Xen Orchestra user. + - If the value is not specified in the inventory configuration, the value of environment variable C(ANSIBLE_XO_USER) will be used instead. + required: true + type: str + env: + - name: ANSIBLE_XO_USER + password: + description: + - Xen Orchestra password. + - If the value is not specified in the inventory configuration, the value of environment variable C(ANSIBLE_XO_PASSWORD) will be used instead. + required: true + type: str + env: + - name: ANSIBLE_XO_PASSWORD + validate_certs: + description: Verify TLS certificate if using HTTPS. + type: boolean + default: true + use_ssl: + description: Use wss when connecting to the Xen Orchestra API + type: boolean + default: true +''' + + +EXAMPLES = ''' +# file must be named xen_orchestra.yaml or xen_orchestra.yml +plugin: community.general.xen_orchestra +api_host: 192.168.1.255 +user: xo +password: xo_pwd +validate_certs: true +use_ssl: true +groups: + kube_nodes: "'kube_node' in tags" +compose: + ansible_port: 2222 + +''' + +import json +import ssl +from time import sleep + +from ansible.errors import AnsibleError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable + +from ansible_collections.community.general.plugins.module_utils.version import LooseVersion + +# 3rd party imports +try: + HAS_WEBSOCKET = True + import websocket + from websocket import create_connection + + if LooseVersion(websocket.__version__) <= LooseVersion('1.0.0'): + raise ImportError +except ImportError as e: + HAS_WEBSOCKET = False + + +HALTED = 'Halted' +PAUSED = 'Paused' +RUNNING = 'Running' +SUSPENDED = 'Suspended' +POWER_STATES = [RUNNING, HALTED, SUSPENDED, PAUSED] +HOST_GROUP = 'xo_hosts' +POOL_GROUP = 'xo_pools' + + +def clean_group_name(label): + return label.lower().replace(' ', '-').replace('-', '_') + + +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): + ''' Host inventory parser for ansible using XenOrchestra as source. ''' + + NAME = 'community.general.xen_orchestra' + + def __init__(self): + + super(InventoryModule, self).__init__() + + # from config + self.counter = -1 + self.session = None + self.cache_key = None + self.use_cache = None + + @property + def pointer(self): + self.counter += 1 + return self.counter + + def create_connection(self, xoa_api_host): + validate_certs = self.get_option('validate_certs') + use_ssl = self.get_option('use_ssl') + proto = 'wss' if use_ssl else 'ws' + + sslopt = None if validate_certs else {'cert_reqs': ssl.CERT_NONE} + self.conn = create_connection( + '{0}://{1}/api/'.format(proto, xoa_api_host), sslopt=sslopt) + + CALL_TIMEOUT = 100 + """Number of 1/10ths of a second to wait before method call times out.""" + + def call(self, method, params): + """Calls a method on the XO server with the provided parameters.""" + id = self.pointer + self.conn.send(json.dumps({ + 'id': id, + 'jsonrpc': '2.0', + 'method': method, + 'params': params + })) + + waited = 0 + while waited < self.CALL_TIMEOUT: + response = json.loads(self.conn.recv()) + if 'id' in response and response['id'] == id: + return response + else: + sleep(0.1) + waited += 1 + + raise AnsibleError( + 'Method call {method} timed out after {timeout} seconds.'.format(method=method, timeout=self.CALL_TIMEOUT / 10)) + + def login(self, user, password): + result = self.call('session.signIn', { + 'username': user, 'password': password + }) + + if 'error' in result: + raise AnsibleError( + 'Could not connect: {0}'.format(result['error'])) + + def get_object(self, name): + answer = self.call('xo.getAllObjects', {'filter': {'type': name}}) + + if 'error' in answer: + raise AnsibleError( + 'Could not request: {0}'.format(answer['error'])) + + return answer['result'] + + def _get_objects(self): + self.create_connection(self.xoa_api_host) + self.login(self.xoa_user, self.xoa_password) + + return { + 'vms': self.get_object('VM'), + 'pools': self.get_object('pool'), + 'hosts': self.get_object('host'), + } + + def _apply_constructable(self, name, variables): + strict = self.get_option('strict') + self._add_host_to_composed_groups(self.get_option('groups'), variables, name, strict=strict) + self._add_host_to_keyed_groups(self.get_option('keyed_groups'), variables, name, strict=strict) + self._set_composite_vars(self.get_option('compose'), variables, name, strict=strict) + + def _add_vms(self, vms, hosts, pools): + for uuid, vm in vms.items(): + group = 'with_ip' + ip = vm.get('mainIpAddress') + entry_name = uuid + power_state = vm['power_state'].lower() + pool_name = self._pool_group_name_for_uuid(pools, vm['$poolId']) + host_name = self._host_group_name_for_uuid(hosts, vm['$container']) + + self.inventory.add_host(entry_name) + + # Grouping by power state + self.inventory.add_child(power_state, entry_name) + + # Grouping by host + if host_name: + self.inventory.add_child(host_name, entry_name) + + # Grouping by pool + if pool_name: + self.inventory.add_child(pool_name, entry_name) + + # Grouping VMs with an IP together + if ip is None: + group = 'without_ip' + self.inventory.add_group(group) + self.inventory.add_child(group, entry_name) + + # Adding meta + self.inventory.set_variable(entry_name, 'uuid', uuid) + self.inventory.set_variable(entry_name, 'ip', ip) + self.inventory.set_variable(entry_name, 'ansible_host', ip) + self.inventory.set_variable(entry_name, 'power_state', power_state) + self.inventory.set_variable( + entry_name, 'name_label', vm['name_label']) + self.inventory.set_variable(entry_name, 'type', vm['type']) + self.inventory.set_variable( + entry_name, 'cpus', vm['CPUs']['number']) + self.inventory.set_variable(entry_name, 'tags', vm['tags']) + self.inventory.set_variable( + entry_name, 'memory', vm['memory']['size']) + self.inventory.set_variable( + entry_name, 'has_ip', group == 'with_ip') + self.inventory.set_variable( + entry_name, 'is_managed', vm.get('managementAgentDetected', False)) + self.inventory.set_variable( + entry_name, 'os_version', vm['os_version']) + + self._apply_constructable(entry_name, self.inventory.get_host(entry_name).get_vars()) + + def _add_hosts(self, hosts, pools): + for host in hosts.values(): + entry_name = host['uuid'] + group_name = 'xo_host_{0}'.format( + clean_group_name(host['name_label'])) + pool_name = self._pool_group_name_for_uuid(pools, host['$poolId']) + + self.inventory.add_group(group_name) + self.inventory.add_host(entry_name) + self.inventory.add_child(HOST_GROUP, entry_name) + self.inventory.add_child(pool_name, entry_name) + + self.inventory.set_variable(entry_name, 'enabled', host['enabled']) + self.inventory.set_variable( + entry_name, 'hostname', host['hostname']) + self.inventory.set_variable(entry_name, 'memory', host['memory']) + self.inventory.set_variable(entry_name, 'address', host['address']) + self.inventory.set_variable(entry_name, 'cpus', host['cpus']) + self.inventory.set_variable(entry_name, 'type', 'host') + self.inventory.set_variable(entry_name, 'tags', host['tags']) + self.inventory.set_variable(entry_name, 'version', host['version']) + self.inventory.set_variable( + entry_name, 'power_state', host['power_state'].lower()) + self.inventory.set_variable( + entry_name, 'product_brand', host['productBrand']) + + for pool in pools.values(): + group_name = 'xo_pool_{0}'.format( + clean_group_name(pool['name_label'])) + + self.inventory.add_group(group_name) + + def _add_pools(self, pools): + for pool in pools.values(): + group_name = 'xo_pool_{0}'.format( + clean_group_name(pool['name_label'])) + + self.inventory.add_group(group_name) + + # TODO: Refactor + def _pool_group_name_for_uuid(self, pools, pool_uuid): + for pool in pools: + if pool == pool_uuid: + return 'xo_pool_{0}'.format( + clean_group_name(pools[pool_uuid]['name_label'])) + + # TODO: Refactor + def _host_group_name_for_uuid(self, hosts, host_uuid): + for host in hosts: + if host == host_uuid: + return 'xo_host_{0}'.format( + clean_group_name(hosts[host_uuid]['name_label'] + )) + + def _populate(self, objects): + # Prepare general groups + self.inventory.add_group(HOST_GROUP) + self.inventory.add_group(POOL_GROUP) + for group in POWER_STATES: + self.inventory.add_group(group.lower()) + + self._add_pools(objects['pools']) + self._add_hosts(objects['hosts'], objects['pools']) + self._add_vms(objects['vms'], objects['hosts'], objects['pools']) + + def verify_file(self, path): + + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith(('xen_orchestra.yaml', 'xen_orchestra.yml')): + valid = True + else: + self.display.vvv( + 'Skipping due to inventory source not ending in "xen_orchestra.yaml" nor "xen_orchestra.yml"') + return valid + + def parse(self, inventory, loader, path, cache=True): + if not HAS_WEBSOCKET: + raise AnsibleError('This plugin requires websocket-client 1.0.0 or higher: ' + 'https://github.com/websocket-client/websocket-client.') + + super(InventoryModule, self).parse(inventory, loader, path) + + # read config from file, this sets 'options' + self._read_config_data(path) + self.inventory = inventory + + self.protocol = 'wss' + self.xoa_api_host = self.get_option('api_host') + self.xoa_user = self.get_option('user') + self.xoa_password = self.get_option('password') + self.cache_key = self.get_cache_key(path) + self.use_cache = cache and self.get_option('cache') + + self.validate_certs = self.get_option('validate_certs') + if not self.get_option('use_ssl'): + self.protocol = 'ws' + + objects = self._get_objects() + self._populate(objects) |