diff --git a/changelogs/fragments/91-add-esxi_host-inventory.yml b/changelogs/fragments/91-add-esxi_host-inventory.yml new file mode 100644 index 00000000..f6f374df --- /dev/null +++ b/changelogs/fragments/91-add-esxi_host-inventory.yml @@ -0,0 +1,3 @@ +--- +minor_changes: + - esxi_host - Added inventory plugin to gather info about ESXi hosts diff --git a/plugins/doc_fragments/plugin_base_options.py b/plugins/doc_fragments/plugin_base_options.py new file mode 100644 index 00000000..8070ab5d --- /dev/null +++ b/plugins/doc_fragments/plugin_base_options.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2016, Charles Paul +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2019, Abhijeet Kasurde +# 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 + + +class ModuleDocFragment(object): + # This document fragment serves as a partial base for all vmware plugins. It should be used in addition to the base fragment, vmware.vmware.base_options + # since that contains the actual argument descriptions and defaults. This just defines the environment variables since plugins have something + # like the module spec where that is usually done. + DOCUMENTATION = r''' +options: + hostname: + env: + - name: VMWARE_HOST + username: + env: + - name: VMWARE_USER + password: + env: + - name: VMWARE_PASSWORD + validate_certs: + env: + - name: VMWARE_VALIDATE_CERTS + port: + env: + - name: VMWARE_PORT + proxy_host: + env: + - name: VMWARE_PROXY_HOST + proxy_port: + env: + - name: VMWARE_PROXY_PORT +''' diff --git a/plugins/inventory/esxi_hosts.py b/plugins/inventory/esxi_hosts.py new file mode 100644 index 00000000..6497a246 --- /dev/null +++ b/plugins/inventory/esxi_hosts.py @@ -0,0 +1,439 @@ +# Copyright: (c) 2024, Ansible Cloud Team +# 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: esxi_hosts +short_description: Create an inventory containing VMware ESXi hosts +author: + - Ansible Cloud Team (@ansible-collections) +description: + - Create a dynamic inventory of VMware ESXi hosts from a vCenter environment. + - Uses any file which ends with esxi_hosts.yml, esxi_hosts.yaml, vmware_esxi_hosts.yml, or vmware_esxi_hosts.yaml as a YAML configuration file. + +extends_documentation_fragment: + - vmware.vmware.base_options + - vmware.vmware.additional_rest_options + - vmware.vmware.plugin_base_options + - ansible.builtin.inventory_cache + - ansible.builtin.constructed + +requirements: + - vSphere Automation SDK (when gather_tags is True) + +options: + gather_tags: + description: + - If true, gather any tags attached to the associated ESXi hosts + - Requires 'vSphere Automation SDK' library to be installed on the Ansible controller machine. + default: false + type: bool + hostnames: + description: + - A list of templates evaluated in order to compose inventory_hostname. + - Each value in the list should be a jinja template. You can see the examples section for more details. + - Templates that result in an empty string or None value are ignored and the next template is evaluated. + - You can use hostvars such as properties specified in O(properties) as variables in the template. + type: list + elements: string + default: ['name'] + properties: + description: + - Specify a list of VMware schema properties associated with the ESXi hostsystem to collectio and return as hostvars. + - Each value in the list can be a path to a specific property in hostsystem object or a path to a collection of hostsystem objects. + - Please make sure that if you use a property in another parameter that is is included in this option. + - Some properties are always returned, such as name, customValue, and summary.runtime.powerState + - Use V(all) to return all properties available for the ESXi host. + type: list + elements: string + default: ['name', 'customValue', 'summary.runtime.powerState'] + flatten_nested_properties: + description: + - If true, flatten any nested properties into their dot notation names. + - For example 'summary["runtime"]["powerState"]' would become "summary.runtime.powerState" + type: bool + default: false + keyed_groups: + description: + - Use the values of ESXi host properties or other hostvars to create and populate groups. + type: list + default: [{key: 'summary.runtime.powerState', separator: ''}] + search_paths: + description: + - Specify a list of paths that should be searched recursively for hosts. + - This effectively allows you to only include hosts in certain datacenters, clusters, or folders. + - >- + Filtering is done before the initial host gathering query. If you have a large number of hosts, specifying + a subset of paths to search can help speed up the inventory plugin. + - The default value is an empty list, which means all paths (i.e. all datacenters) will be searched. + type: list + elements: str + default: [] + group_by_paths: + description: + - If true, groups will be created based on the ESXI hosts' paths. + - Paths will be sanitized to match Ansible group name standards. For example, any slashes or dashes in the paths will be replaced by underscores in the group names. + - A group is created for each step down in the path, with the group from the step above containing subsequent groups. + - For example, a path /DC-01/hosts/Cluster will create groups 'DC_01' which contains group 'DC_01_hosts' which contains group 'DC_01_hosts_Cluster' + default: false + type: bool + group_by_paths_prefix: + description: + - If O(group_by_paths) is true, set this variable if you want to add a prefix to any groups created based on paths. + - By default, no prefix is added to the group names. + default: '' + type: str + sanitize_property_names: + description: + - If true, sanitize ESXi host property names so they can safely be referenced within Ansible playbooks. + - This option also transforms property names to snake case. For example, powerState would become power_state. + type: bool + default: false +""" + +EXAMPLES = r""" +# Below are examples of inventory configuration files that can be used with this plugin. +# To test these and see the resulting inventory, save the snippet in a file named hosts.vmware_esxi.yml and run: +# ansible-inventory -i hosts.vmware_esxi.yml --list + + +# Simple configuration with in-file authentication parameters +plugin: vmware.vmware.esxi_hosts +hostname: 10.65.223.31 +username: administrator@vsphere.local +password: Esxi@123$% +validate_certs: false + + +# More complex configuration. Authentication parameters are assumed to be set as environment variables. +plugin: vmware.vmware.esxi_hosts + +# Create groups based on host paths +group_by_paths: true + +# Create a group with hosts that support vMotion using the vmotionSupported property +properties: ["name", "capability"] +groups: + vmotion_supported: capability.vmotionSupported + +# Only gather hosts found in certain paths +search_paths: + - /DC1/host/ClusterA + - /DC1/host/ClusterC + - /DC3 + +# Set custom inventory hostnames based on attributes +hostnames: + - "'ESXi - ' + name + ' - ' + management_ip" + - "'ESXi - ' + name" + +# Use compose to set variables for the hosts that we find +compose: + ansible_user: "'root'" + ansible_connection: "'ssh'" + # assuming path is something like /MyDC/host/MyCluster + datacenter: "(path | split('/'))[1]" + cluster: "(path | split('/'))[3]" +""" + +try: + from pyVmomi import vim +except ImportError: + # Already handled in base class + pass + +from ansible.errors import AnsibleError +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict + +from ansible_collections.vmware.vmware.plugins.inventory_utils._base import VmwareInventoryBase +from ansible_collections.vmware.vmware.plugins.module_utils._vmware_folder_paths import ( + get_folder_path_of_vsphere_object +) +from ansible_collections.vmware.vmware.plugins.module_utils._vmware_facts import ( + vmware_obj_to_json, + flatten_dict +) + + +class EsxiInventoryHost(): + def __init__(self): + self.object = None + self.inventory_hostname = None + self.path = '' + self.properties = dict() + self._management_ip = None + + @classmethod + def create_from_cache(cls, inventory_hostname, host_properties): + """ + Create the class from the inventory cache. We dont want to refresh the data or make any calls to vCenter. + Properties are populated from whatever we had previously cached. + """ + host = cls() + host.inventory_hostname = inventory_hostname + host.properties = host_properties + return host + + @classmethod + def create_from_object(cls, host_object, properties_to_gather, pyvmomi_client): + """ + Create the class from a host object that we got from pyvmomi. The host properties will be populated + from the object and additional calls to vCenter + """ + host = cls() + host.object = host_object + host.path = get_folder_path_of_vsphere_object(host_object) + host.properties = host._set_properties_from_pyvmomi(properties_to_gather, pyvmomi_client) + return host + + def _set_properties_from_pyvmomi(self, properties_to_gather, pyvmomi_client): + properties = vmware_obj_to_json(self.object, properties_to_gather) + properties['path'] = self.path + properties['management_ip'] = self.management_ip + + # Custom values + if hasattr(self.object, "customValue"): + properties['customValue'] = dict() + field_mgr = pyvmomi_client.custom_field_mgr + for cust_value in self.object.customValue: + properties['customValue'][ + [y.name for y in field_mgr if y.key == cust_value.key][0] + ] = cust_value.value + + return properties + + def sanitize_properties(self): + self.properties = camel_dict_to_snake_dict(self.properties) + + def flatten_properties(self): + self.properties = flatten_dict(self.properties) + + @property + def management_ip(self): + # We already looked up the management IP from vcenter this session, so + # reuse that value + if self._management_ip is not None: + return self._management_ip + + # If this is an object created from the cache, we won't be able to access + # vcenter. But we stored the management IP in the properties when we originally + # created the object (before the cache) so use that value + try: + return self.properties['management_ip'] + except KeyError: + pass + + # Finally, try to find the IP from vcenter. It might not exist, in which case we + # return an empty string + try: + vnic_manager = self.object.configManager.virtualNicManager + net_config = vnic_manager.QueryNetConfig("management") + for nic in net_config.candidateVnic: + if nic.key in net_config.selectedVnic: + self._management_ip = nic.spec.ip.ipAddress + except Exception: + self._management_ip = "" + + return self._management_ip + + +class InventoryModule(VmwareInventoryBase): + + NAME = "vmware.vmware.esxi_hosts" + + def verify_file(self, path): + """ + Checks the plugin configuration file format and name, and returns True + if everything is valid. + Args: + path: Path to the configuration YAML file + Returns: + True if everything is correct, else False + """ + if super(InventoryModule, self).verify_file(path): + return path.endswith( + ( + "esxi_hosts.yml", + "esxi_hosts.yaml", + "vmware_esxi_hosts.yaml", + "vmware_esxi_hosts.yml" + ) + ) + return False + + def parse(self, inventory, loader, path, cache=True): + """ + Parses the inventory file options and creates an inventory based on those inputs + """ + super(InventoryModule, self).parse(inventory, loader, path, cache=cache) + cache_key = self.get_cache_key(path) + result_was_cached, results = self.get_cached_result(cache, cache_key) + + if result_was_cached: + self.populate_from_cache(results) + else: + results = self.populate_from_vcenter(self._read_config_data(path)) + + self.update_cached_result(cache, cache_key, results) + + def parse_properties_param(self): + """ + The properties option can be a variety of inputs from the user and we need to + manipulate it into a list of props that can be used later. + Returns: + A list of property names that should be returned in the inventory. An empty + list means all properties should be collected + """ + properties_param = self.get_option("properties") + if not isinstance(properties_param, list): + properties_param = [properties_param] + + if "all" in properties_param: + return [] + + if "name" not in properties_param: + properties_param.append("name") + + if "summary.runtime.connectionState" not in properties_param: + properties_param.append("summary.runtime.connectionState") + + return properties_param + + def populate_from_cache(self, cache_data): + """ + Populate inventory data from cache + """ + for inventory_hostname, host_properties in cache_data.items(): + esxi_host = EsxiInventoryHost.create_from_cache( + inventory_hostname=inventory_hostname, + host_properties=host_properties + ) + self.__update_inventory(esxi_host) + + def populate_from_vcenter(self, config_data): + """ + Populate inventory data from vCenter + """ + hostvars = {} + properties_to_gather = self.parse_properties_param() + self.initialize_pyvmomi_client(config_data) + if self.get_option("gather_tags"): + self.initialize_rest_client(config_data) + + for host_object in self.get_objects_by_type(vim_type=[vim.HostSystem]): + if host_object.runtime.connectionState in ("disconnected", "notResponding"): + continue + + esxi_host = EsxiInventoryHost.create_from_object( + host_object=host_object, + properties_to_gather=properties_to_gather, + pyvmomi_client=self.pyvmomi_client + ) + + if self.get_option("gather_tags"): + tags, tags_by_category = self.gather_tags(esxi_host.object._GetMoId()) + esxi_host.properties["tags"] = tags + esxi_host.properties["tags_by_category"] = tags_by_category + + self.set_inventory_hostname(esxi_host) + if esxi_host.inventory_hostname not in hostvars: + hostvars[esxi_host.inventory_hostname] = esxi_host.properties + self.__update_inventory(esxi_host) + + return hostvars + + def __update_inventory(self, esxi_host): + self.add_host_to_inventory(esxi_host) + self.add_host_to_groups_based_on_path(esxi_host) + self.set_host_variables_from_host_properties(esxi_host) + + def set_inventory_hostname(self, esxi_host): + """ + The user can specify a list of jinja templates, and the first valid template should be used for the + host's inventory hostname. The inventory hostname is mostly for decrative purposes since the + ansible_host value takes precedence when trying to connect. + """ + hostname = None + errors = [] + + for hostname_pattern in self.get_option("hostnames"): + try: + hostname = self._compose(template=hostname_pattern, variables=esxi_host.properties) + except Exception as e: + if self.get_option("strict"): + raise AnsibleError( + "Could not compose %s as hostnames - %s" + % (hostname_pattern, to_native(e)) + ) + + errors.append((hostname_pattern, str(e))) + if hostname: + esxi_host.inventory_hostname = hostname + return + + raise AnsibleError( + "Could not template any hostname for host, errors for each preference: %s" + % (", ".join(["%s: %s" % (pref, err) for pref, err in errors])) + ) + + def add_host_to_inventory(self, esxi_host: EsxiInventoryHost): + """ + Add the host to the inventory and any groups that the user wants to create based on inventory + parameters like groups or keyed groups. + """ + strict = self.get_option("strict") + self.inventory.add_host(esxi_host.inventory_hostname) + self.inventory.set_variable(esxi_host.inventory_hostname, "ansible_host", esxi_host.management_ip) + + self._set_composite_vars( + self.get_option("compose"), esxi_host.properties, esxi_host.inventory_hostname, strict=strict) + self._add_host_to_composed_groups( + self.get_option("groups"), esxi_host.properties, esxi_host.inventory_hostname, strict=strict) + self._add_host_to_keyed_groups( + self.get_option("keyed_groups"), esxi_host.properties, esxi_host.inventory_hostname, strict=strict) + + def add_host_to_groups_based_on_path(self, esxi_host: EsxiInventoryHost): + """ + If the user desires, create groups based on each ESXi host's path. A group is created for each + step down in the path, with the group from the step above containing subsequent groups. + Optionally, the user can add a prefix to the groups created by this process. + The final group in the path will be where the ESXi host is added. + """ + if not self.get_option("group_by_paths"): + return + + path_parts = esxi_host.path.split('/') + group_name_parts = [] + last_created_group = None + + if self.get_option("group_by_paths_prefix"): + group_name_parts = [self.get_option("group_by_paths_prefix")] + + for path_part in path_parts: + if not path_part: + continue + group_name_parts.append(path_part) + group_name = self._sanitize_group_name('_'.join(group_name_parts)) + group = self.inventory.add_group(group_name) + + if last_created_group: + self.inventory.add_child(last_created_group, group) + last_created_group = group + + if last_created_group: + self.inventory.add_host(esxi_host.inventory_hostname, last_created_group) + + def set_host_variables_from_host_properties(self, esxi_host): + if self.get_option("sanitize_property_names"): + esxi_host.sanitize_properties() + + if self.get_option("flatten_nested_properties"): + esxi_host.flatten_properties() + + for k, v in esxi_host.properties.items(): + self.inventory.set_variable(esxi_host.inventory_hostname, k, v) diff --git a/plugins/inventory_utils/_base.py b/plugins/inventory_utils/_base.py new file mode 100644 index 00000000..1dd27c6f --- /dev/null +++ b/plugins/inventory_utils/_base.py @@ -0,0 +1,187 @@ +# Copyright: (c) 2024, Ansible Cloud Team +# 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 + +from ansible.errors import AnsibleParserError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable +from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.vmware.vmware.plugins.module_utils.clients._pyvmomi import PyvmomiClient +from ansible_collections.vmware.vmware.plugins.module_utils.clients._rest import VmwareRestClient + + +class VmwareInventoryBase(BaseInventoryPlugin, Constructable, Cacheable): + + def initialize_pyvmomi_client(self, config_data): + """ + Create an instance of the pyvmomi client based on the user's input (auth) parameters + """ + # update _options from config data + self._consume_options(config_data) + + username, password = self.get_credentials_from_options() + + try: + self.pyvmomi_client = PyvmomiClient({ + 'hostname': self.get_option("hostname"), + 'username': username, + 'password': password, + 'port': self.get_option("port"), + 'validate_certs': self.get_option("validate_certs"), + 'http_proxy_host': self.get_option("proxy_host"), + 'http_proxy_port': self.get_option("proxy_port") + }) + except Exception as e: + raise AnsibleParserError(message=to_native(e)) + + def initialize_rest_client(self, config_data): + """ + Create an instance of the REST client based on the user's input (auth) parameters + """ + # update _options from config data + self._consume_options(config_data) + + username, password = self.get_credentials_from_options() + + try: + self.rest_client = VmwareRestClient({ + 'hostname': self.get_option("hostname"), + 'username': username, + 'password': password, + 'port': self.get_option("port"), + 'validate_certs': self.get_option("validate_certs"), + 'http_proxy_host': self.get_option("proxy_host"), + 'http_proxy_port': self.get_option("proxy_port"), + 'http_proxy_protocol': self.get_option("proxy_protocol") + }) + except Exception as e: + raise AnsibleParserError(message=to_native(e)) + + def get_credentials_from_options(self): + """ + The username and password options can be plain text, jinja templates, or encrypted strings. + This method handles these different options and returns a plain text version of username and password + Returns: + A tuple of plain text username and password + """ + username = self.get_option("username") + password = self.get_option("password") + + if self.templar.is_template(password): + password = self.templar.template(variable=password, disable_lookups=False) + elif isinstance(password, AnsibleVaultEncryptedUnicode): + password = password.data + + if self.templar.is_template(username): + username = self.templar.template(variable=username, disable_lookups=False) + elif isinstance(username, AnsibleVaultEncryptedUnicode): + username = username.data + + return (username, password) + + def get_cached_result(self, cache, cache_key): + """ + Checks if a cache is available and if there's already data in the cache for this plugin. + Returns the data if some is found. + Relies on the caching mechanism found in the Ansible base classes + Args: + cache: bool, True if the plugin should use a cache + cache_key: str, The key where data is stored in the cache + Returns: + tuple(bool, dict or None) + First value indicates if a cached result was found + Second value is the cached data. Cached data could be empty, which is why the first value is needed. + """ + # false when refresh_cache or --flush-cache is used + if not cache: + return False, None + + # check user-specified directive + if not self.get_option("cache"): + return False, None + + try: + cached_value = self._cache[cache_key] + except KeyError: + # if cache expires or cache file doesn"t exist + return False, None + + return True, cached_value + + def update_cached_result(self, cache, cache_key, result): + """ + If the user wants to use a cache, add the new results to the cache. + Args: + cache: bool, True if the plugin should use a cache + cache_key: str, The key where data is stored in the cache + result: dict, The data to store in the cache + Returns: + None + """ + if not self.get_option("cache"): + return + + # We weren't explicitly told to flush the cache, and there's already a cache entry, + # this means that the result we're being passed came from the cache. As such we don't + # want to "update" the cache as that could reset a TTL on the cache entry. + if cache and cache_key in self._cache: + return + + self._cache[cache_key] = result + + def get_objects_by_type(self, vim_type): + """ + Searches the requested search paths for objects of type vim_type. If the search path + doesn't actually exist, continue. If no search path is give, check everywhere + Args: + vim_type: The vim object type. It should be given as a list, like [vim.HostSystem] + Returns: + List of objects that exist in the search path(s) and match vim type + """ + if not self.get_option('search_paths'): + return self.pyvmomi_client.get_all_objs_by_type(vimtype=vim_type) + + objects = [] + for search_path in self.get_option('search_paths'): + folder = self.pyvmomi_client.si.content.searchIndex.FindByInventoryPath(search_path) + if not folder: + continue + objects += self.pyvmomi_client.get_all_objs_by_type(vimtype=vim_type, folder=folder) + + return objects + + def gather_tags(self, object_moid): + """ + Given an object moid, gather any tags attached to the object. + Args: + object_moid: str, The objects MOID + Returns: + tuple + First item is a dict with the object's tags. Keys are tag IDs and values are tag names + Second item is a dict of the object's tag categories. Keys are category names and values are a dict + containing the tags in the category + """ + if not hasattr(self, '_known_tag_category_ids_to_name'): + self._known_tag_category_ids_to_name = {} + + tags = {} + tags_by_category = {} + for tag in self.rest_client.get_tags_by_host_moid(object_moid): + tags[tag.id] = tag.name + try: + category_name = self._known_tag_category_ids_to_name[tag.category_id] + except KeyError: + category_name = self.rest_client.tag_category_service.get(tag.category_id).name + self._known_tag_category_ids_to_name[tag.category_id] = category_name + + if not tags_by_category.get(category_name): + tags_by_category[category_name] = [] + + tags_by_category[category_name].append({tag.id: tag.name}) + + return tags, tags_by_category diff --git a/plugins/module_utils/_vmware_facts.py b/plugins/module_utils/_vmware_facts.py index 8424dcfc..18fa5798 100644 --- a/plugins/module_utils/_vmware_facts.py +++ b/plugins/module_utils/_vmware_facts.py @@ -647,3 +647,28 @@ def vmware_obj_to_json(obj, properties=None): else: result = _jsonify_vmware_object(obj) return result + + +def flatten_dict(dictionary, separator=".", parent_key=""): + """ + Changes nested dictionary keys to be their dot notation versions, so the dictionary + only has one level of depth. + For example {"foo":{"bar":1}} would become {"foo.bar":1} + Args: + dictionary: dict, The original dictionary + separator: str, A character to use to separate keys once they are flattened + parent_key: str, Used as part of the recursion inside this method. + Returns: + dict + """ + new_dict_items = [] + for k, v in dictionary.items(): + new_key = parent_key + separator + k if parent_key else k + if v and isinstance(v, dict): + new_dict_items.extend( + flatten_dict(dictionary=v, separator=separator, parent_key=new_key) + .items() + ) + else: + new_dict_items.append((new_key, v)) + return dict(new_dict_items) diff --git a/plugins/module_utils/clients/_errors.py b/plugins/module_utils/clients/_errors.py new file mode 100644 index 00000000..285ed113 --- /dev/null +++ b/plugins/module_utils/clients/_errors.py @@ -0,0 +1,14 @@ +from ansible.module_utils.basic import missing_required_lib + + +class ApiAccessError(Exception): + def __init__(self, *args, **kwargs): + super(ApiAccessError, self).__init__(*args, **kwargs) + + +class MissingLibError(Exception): + def __init__(self, library, exception, url=None): + self.exception = exception + self.library = library + self.url = url + super().__init__(msg=missing_required_lib(self.library, url=self.url)) diff --git a/plugins/module_utils/clients/_pyvmomi.py b/plugins/module_utils/clients/_pyvmomi.py new file mode 100644 index 00000000..5d58c2dc --- /dev/null +++ b/plugins/module_utils/clients/_pyvmomi.py @@ -0,0 +1,227 @@ +# Copyright: (c) 2024, Ansible Cloud Team +# 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 + +import ssl +import atexit +import traceback + +try: + # requests is required for exception handling of the ConnectionError + import requests + REQUESTS_IMP_ERR = None +except ImportError: + REQUESTS_IMP_ERR = traceback.format_exc() + +try: + from pyVim import connect + from pyVmomi import vim, vmodl + PYVMOMI_IMP_ERR = None +except ImportError: + PYVMOMI_IMP_ERR = traceback.format_exc() + +from ansible_collections.vmware.vmware.plugins.module_utils.clients._errors import ( + ApiAccessError, + MissingLibError +) + + +class PyvmomiClient(): + def __init__(self, connection_params): + self.check_requirements() + self.si, self.content = self.connect_to_api(connection_params, return_si=True) + self.custom_field_mgr = [] + if self.content.customFieldsManager: # not an ESXi + self.custom_field_mgr = self.content.customFieldsManager.field + + def connect_to_api(self, connection_params, disconnect_atexit=True, return_si=False): + """ + Connect to the vCenter/ESXi client using the pyvmomi SDK. This creates a service instance + which can then be used programmatically to make calls to vCenter or ESXi + Args: + connection_params: dict, A dictionary with different authentication or connection parameters like + username, password, hostname, etc. The full list is found in the method below. + disconnect_atexit: bool, If true, disconnect the client when the module or plugin finishes. + return_si: bool, If true, return the service instance and the content manager objects. If false, just + return the content manager. There really is no need to set this to false since you can + just ignore the extra return values. This option is here for legacy compatibility + Returns: + If return_si is true + service_instance, service_instance.RetrieveContent() + If return_si is false + service_instance.RetrieveContent() + + """ + hostname = connection_params.get('hostname') + username = connection_params.get('username') + password = connection_params.get('password') + port = connection_params.get('port') + validate_certs = connection_params.get('validate_certs') + http_proxy_host = connection_params.get('http_proxy_host') + http_proxy_port = connection_params.get('http_proxy_port') + + self.__validate_required_connection_params(hostname, username, password) + ssl_context = self.__set_ssl_context(validate_certs) + + service_instance = None + + connection_args = dict( + host=hostname, + port=port, + ) + if ssl_context: + connection_args.update(sslContext=ssl_context) + + if http_proxy_host: + self.__add_proxy_to_connection(self, connection_args) + + service_instance = self.__create_service_instance( + connection_args, username, password, http_proxy_host, http_proxy_port) + + # Disabling atexit should be used in special cases only. + # Such as IP change of the ESXi host which removes the connection anyway. + # Also removal significantly speeds up the return of the module + if disconnect_atexit: + atexit.register(connect.Disconnect, service_instance) + if return_si: + return service_instance, service_instance.RetrieveContent() + return service_instance.RetrieveContent() + + def __validate_required_connection_params(self, hostname, username, password): + """ + Validate the user provided the required connection parameters and throw an error + if they were not found. Usually the module/plugin validation will do this first so + this is more of a safety/sanity check. + """ + if not hostname: + raise ApiAccessError(( + "Hostname parameter is missing. Please specify this parameter in task or " + "export environment variable like 'export VMWARE_HOST=ESXI_HOSTNAME'" + )) + + if not username: + raise ApiAccessError(( + "Username parameter is missing. Please specify this parameter in task or " + "export environment variable like 'export VMWARE_USER=ESXI_USERNAME'" + )) + + if not password: + raise ApiAccessError(( + "Password parameter is missing. Please specify this parameter in task or " + "export environment variable like 'export VMWARE_PASSWORD=ESXI_PASSWORD'" + )) + + def __set_ssl_context(self, validate_certs): + """ + Configure SSL context settings, depending on user inputs + """ + if validate_certs and not hasattr(ssl, 'SSLContext'): + raise ApiAccessError(( + 'pyVim does not support changing verification mode with python < 2.7.9. ' + 'Either update python or use validate_certs=false.' + )) + elif validate_certs: + ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + ssl_context.verify_mode = ssl.CERT_REQUIRED + ssl_context.check_hostname = True + ssl_context.load_default_certs() + elif hasattr(ssl, 'SSLContext'): + ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) + ssl_context.verify_mode = ssl.CERT_NONE + ssl_context.check_hostname = False + else: # Python < 2.7.9 or RHEL/Centos < 7.4 + ssl_context = None + + return ssl_context + + def __create_service_instance(self, connection_args, username, password, http_proxy_host, http_proxy_port): + """ + Attempt to connect to the vCenter/ESXi host and pass the resulting service instance back + """ + error_msg_suffix = '' + try: + if http_proxy_host: + error_msg_suffix = " [proxy: %s:%d]" % (http_proxy_host, http_proxy_port) + connection_args.update(httpProxyHost=http_proxy_host, httpProxyPort=http_proxy_port) + smart_stub = connect.SmartStubAdapter(**connection_args) + session_stub = connect.VimSessionOrientedStub( + smart_stub, connect.VimSessionOrientedStub.makeUserLoginMethod(username, password) + ) + service_instance = vim.ServiceInstance('ServiceInstance', session_stub) + else: + connection_args.update(user=username, pwd=password) + service_instance = connect.SmartConnect(**connection_args) + except vim.fault.InvalidLogin as e: + raise ApiAccessError(( + "Unable to log on to vCenter or ESXi API at %s:%s as %s: %s" % + (connection_args['host'], connection_args['port'], username, e.msg) + + error_msg_suffix + )) + except vim.fault.NoPermission as e: + raise ApiAccessError(( + "User %s does not have required permission to log on to vCenter or ESXi API at %s:%s : %s" % + (username, connection_args['host'], connection_args['port'], e.msg) + )) + except (requests.ConnectionError, ssl.SSLError) as e: + raise ApiAccessError(( + "Unable to connect to vCenter or ESXi API at %s on TCP/%s: %s" % + (connection_args['host'], connection_args['port'], e) + )) + except vmodl.fault.InvalidRequest as e: + raise ApiAccessError(( + "Failed to get a response from server %s:%s as request is malformed: %s" % + (connection_args['host'], connection_args['port'], e.msg) + + error_msg_suffix + )) + except Exception as e: + raise ApiAccessError(( + "Unknown error while connecting to vCenter or ESXi API at %s:%s : %s" % + (connection_args['host'], connection_args['port'], e.msg) + + error_msg_suffix + )) + + if service_instance is None: + raise ApiAccessError(( + "Unknown error while connecting to vCenter or ESXi API at %s:%s" % + (connection_args['host'], connection_args['port']) + + error_msg_suffix + )) + + return service_instance + + def check_requirements(self): + """ + Check all requirements for this client are satisfied + """ + if REQUESTS_IMP_ERR: + raise MissingLibError('requests', REQUESTS_IMP_ERR) + if PYVMOMI_IMP_ERR: + raise MissingLibError('pyvmomi', PYVMOMI_IMP_ERR) + + def get_all_objs_by_type(self, vimtype, folder=None, recurse=True): + """ + Returns a list of all objects matching a given VMWare type. + You can also limit the search by folder and recurse if desired + Args: + vimtype: The type of object to search for + folder: vim.Folder, the folder object to use as a base for the search. If + none is provided, the datacenter root will be used + recurse: If true, the search will recurse through the folder structure + Returns: + list of objs + """ + if not folder: + folder = self.content.rootFolder + + objs = [] + container = self.content.viewManager.CreateContainerView(folder, vimtype, recurse) + for managed_object_ref in container.view: + try: + objs += [managed_object_ref] + except vmodl.fault.ManagedObjectNotFound: + pass + return objs diff --git a/plugins/module_utils/clients/_rest.py b/plugins/module_utils/clients/_rest.py new file mode 100644 index 00000000..6ac54560 --- /dev/null +++ b/plugins/module_utils/clients/_rest.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Ansible Cloud Team (@ansible-collections) +# 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 +# + +# Note: This utility is considered private, and can only be referenced from inside the vmware.vmware collection. +# It may be made public at a later date + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import traceback + +try: + import requests + REQUESTS_IMP_ERR = None +except ImportError: + REQUESTS_IMP_ERR = traceback.format_exc() + +try: + from vmware.vapi.vsphere.client import create_vsphere_client + from com.vmware.vapi.std_client import DynamicID + VSPHERE_IMP_ERR = None +except ImportError: + VSPHERE_IMP_ERR = traceback.format_exc() + +try: + from requests.packages import urllib3 + HAS_URLLIB3 = True +except ImportError: + try: + import urllib3 + HAS_URLLIB3 = True + except ImportError: + HAS_URLLIB3 = False + +from ansible.module_utils._text import to_native +from ansible_collections.vmware.vmware.plugins.module_utils.clients._errors import ( + ApiAccessError, + MissingLibError +) + + +class VmwareRestClient(): + def __init__(self, connection_params): + self.check_requirements() + self.api_client = self.connect_to_api(connection_params) + + self.library_service = self.api_client.content.Library + self.library_item_service = self.api_client.content.library.Item + + self.tag_service = self.api_client.tagging.Tag + self.tag_association_service = self.api_client.tagging.TagAssociation + self.tag_category_service = self.api_client.tagging.Category + + def check_requirements(self): + """ + Check all requirements for this client are satisfied + """ + if REQUESTS_IMP_ERR: + raise MissingLibError('requests', REQUESTS_IMP_ERR) + if VSPHERE_IMP_ERR: + raise MissingLibError( + 'vSphere Automation SDK', VSPHERE_IMP_ERR, + url='https://code.vmware.com/web/sdk/7.0/vsphere-automation-python' + ) + + def connect_to_api(self, connection_params): + """ + Connect to the vCenter/ESXi client using the REST SDK. This creates a service instance + which can then be used programmatically to make calls to vCenter or ESXi + Args: + connection_params: dict, A dictionary with different authentication or connection parameters like + username, password, hostname, etc. The full list is found in the method below. + Returns: + Authenticated REST client instance + + """ + hostname = connection_params.get('hostname') + username = connection_params.get('username') + password = connection_params.get('password') + port = connection_params.get('port') + validate_certs = connection_params.get('validate_certs') + http_proxy_host = connection_params.get('http_proxy_host') + http_proxy_port = connection_params.get('http_proxy_port') + http_proxy_protocol = connection_params.get('http_proxy_protocol') + + self.__validate_required_connection_params(hostname, username, password) + + session = requests.Session() + + self.__configure_session_ssl_context(session, validate_certs) + self.__configure_session_proxies(session, http_proxy_host, http_proxy_port, http_proxy_protocol) + return self.__create_client_connection(session, hostname, username, password, port) + + def __validate_required_connection_params(self, hostname, username, password): + """ + Validate the user provided the required connection parameters and throw an error + if they were not found. Usually the module/plugin validation will do this first so + this is more of a safety/sanity check. + """ + if not hostname: + raise ApiAccessError(( + "Hostname parameter is missing. Please specify this parameter in task or " + "export environment variable like 'export VMWARE_HOST=ESXI_HOSTNAME'" + )) + + if not username: + raise ApiAccessError(( + "Username parameter is missing. Please specify this parameter in task or " + "export environment variable like 'export VMWARE_USER=ESXI_USERNAME'" + )) + + if not password: + raise ApiAccessError(( + "Password parameter is missing. Please specify this parameter in task or " + "export environment variable like 'export VMWARE_PASSWORD=ESXI_PASSWORD'" + )) + + def __configure_session_ssl_context(self, session, validate_certs): + session.verify = validate_certs + + if not validate_certs: + if HAS_URLLIB3: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + def __configure_session_proxies(self, session, http_proxy_host, http_proxy_port, http_proxy_protocol): + if all([http_proxy_host, http_proxy_port, http_proxy_protocol]): + http_proxies = { + http_proxy_protocol: ( + "{%s}://{%s}:{%s}" % + http_proxy_protocol, http_proxy_host, http_proxy_port + ) + } + + session.proxies.update(http_proxies) + + def __create_client_connection(self, session, hostname, username, password, port): + msg = "Failed to connect to vCenter or ESXi API at %s:%s" % (hostname, port) + try: + client = create_vsphere_client( + server="%s:%s" % (hostname, port), + username=username, + password=password, + session=session + ) + except requests.exceptions.SSLError as e: + msg += " due to SSL verification failure" + raise ApiAccessError(msg="%s : %s" % (msg, to_native(e))) + except Exception as e: + raise ApiAccessError(msg="%s : %s" % (msg, to_native(e))) + + if client is None: + raise ApiAccessError(msg="Failed to login to %s" % hostname) + + return client + + def get_tags_by_vm_moid(self, vm_moid): + """ + Get a list of tag objects attached to a virtual machine + Args: + vm_moid: the VM MOID to use to gather tags + + Returns: + List of tag object associated with the given virtual machine + """ + dobj = DynamicID(type='VirtualMachine', id=vm_moid) + return self.get_tags_for_dynamic_id_obj(dobj=dobj) + + def get_tags_by_host_moid(self, host_moid): + """ + Get a list of tag objects attached to an ESXi host + Args: + host_moid: the Host MOID to use to gather tags + + Returns: + List of tag object associated with the given host + """ + dobj = DynamicID(type='HostSystem', id=host_moid) + return self.get_tags_for_dynamic_id_obj(dobj=dobj) + + def get_tags_for_dynamic_id_obj(self, dobj): + """ + Return tag objects associated with an DynamicID object. + Args: + dobj: Dynamic object + Returns: + List of tag objects associated with the given object + """ + tags = [] + if not dobj: + return tags + + tag_ids = self.tag_association_service.list_attached_tags(dobj) + for tag_id in tag_ids: + tags.append(self.tag_service.get(tag_id)) + + return tags diff --git a/tests/integration/targets/vmware_inventory_esxi_hosts/defaults/main.yml b/tests/integration/targets/vmware_inventory_esxi_hosts/defaults/main.yml new file mode 100644 index 00000000..539b766c --- /dev/null +++ b/tests/integration/targets/vmware_inventory_esxi_hosts/defaults/main.yml @@ -0,0 +1 @@ +run_on_simulator: false diff --git a/tests/integration/targets/vmware_inventory_esxi_hosts/files/test.esxi_hosts.yml b/tests/integration/targets/vmware_inventory_esxi_hosts/files/test.esxi_hosts.yml new file mode 100644 index 00000000..00b5173c --- /dev/null +++ b/tests/integration/targets/vmware_inventory_esxi_hosts/files/test.esxi_hosts.yml @@ -0,0 +1,6 @@ +--- +plugin: vmware.vmware.esxi_hosts +cache: false +group_by_paths: true +group_by_paths_prefix: test +gather_tags: true diff --git a/tests/integration/targets/vmware_inventory_esxi_hosts/run.yml b/tests/integration/targets/vmware_inventory_esxi_hosts/run.yml new file mode 100644 index 00000000..55b14107 --- /dev/null +++ b/tests/integration/targets/vmware_inventory_esxi_hosts/run.yml @@ -0,0 +1,13 @@ +- hosts: localhost + gather_facts: no + tasks: + - name: Import eco-vcenter credentials + ansible.builtin.include_vars: + file: ../../integration_config.yml + tags: eco-vcenter-ci + + - name: Call esxi_hosts inventory role + ansible.builtin.import_role: + name: vmware_inventory_esxi_hosts + tags: + - eco-vcenter-ci diff --git a/tests/integration/targets/vmware_inventory_esxi_hosts/tasks/main.yml b/tests/integration/targets/vmware_inventory_esxi_hosts/tasks/main.yml new file mode 100644 index 00000000..32f00c95 --- /dev/null +++ b/tests/integration/targets/vmware_inventory_esxi_hosts/tasks/main.yml @@ -0,0 +1,29 @@ +--- +- block: + - name: Import common vars + ansible.builtin.include_vars: + file: ../group_vars.yml + + - name: Run Inventory Plugin + ansible.builtin.command: ansible-inventory -i "{{ role_path }}/files/test.esxi_hosts.yml" --list + register: _inventory_out + + - name: Parse Inventory Results as JSON + ansible.builtin.set_fact: + inventory_results: "{{ _inventory_out.stdout | from_json }}" + + # you can't reference the 'all' property here for some reason. It reverts back to the test playbook inventory + # instead of the inventory_results + - name: Check Output + ansible.builtin.assert: + that: + - first_host.ansible_host + - first_host.tags is defined + - first_host.tags_by_category is defined + - >- + (inventory_results.poweredOn.hosts | length) == + (inventory_results._meta.hostvars.values() | selectattr('summary.runtime.powerState', 'equalto', 'poweredOn') | length) + - (inventory_results | length) > 3 + - ('test_' + vcenter_datacenter | replace('-', '_')) in inventory_results.keys() + vars: + first_host: "{{ (inventory_results._meta.hostvars.values() | first) }}"