#!/usr/bin/env python # -*- coding: utf-8 -*- # Copyright (c) 2012, Marco Vito Moscaritolo # Copyright (c) 2013, Jesse Keating # Copyright (c) 2015, Hewlett-Packard Development Company, L.P. # Copyright (c) 2016, Rackspace Australia # Copyright (c) 2017 Ansible Project # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) DOCUMENTATION = r''' name: openstack author: OpenStack Ansible SIG short_description: OpenStack inventory source description: - Gather servers from OpenStack clouds and add them as Ansible hosts to your inventory. - Use YAML configuration file C(openstack.{yaml,yml}) to configure this inventory plugin. - Consumes cloud credentials from standard YAML configuration files C(clouds{,-public}.yaml). options: all_projects: description: - Lists servers from all projects type: bool default: false clouds_yaml_path: description: - Override path to C(clouds.yaml) file. - If this value is given it will be searched first. - Search paths for cloud credentials are complemented with files C(/etc/ansible/openstack.{yaml,yml}). - Default search paths are documented in U(https://docs.openstack.org/os-client-config/latest/user/configuration.html#config-files). type: list elements: str env: - name: OS_CLIENT_CONFIG_FILE expand_hostvars: description: - Enrich server facts with additional queries to OpenStack services. This includes requests to Cinder and Neutron which can be time-consuming for clouds with many servers. - Default value of I(expand_hostvars) is opposite of the default value for option C(expand_hostvars) in legacy openstack.py inventory script. type: bool default: false fail_on_errors: description: - Whether the inventory script fails, returning no hosts, when connection to a cloud failed, for example due to bad credentials or connectivity issues. - When I(fail_on_errors) is C(false) this inventory script will return all hosts it could fetch from clouds on a best effort basis. - Default value of I(fail_on_errors) is opposite of the default value for option C(fail_on_errors) in legacy openstack.py inventory script. type: bool default: false inventory_hostname: description: - What to register as inventory hostname. - When set to C(uuid) the ID of a server will be used and a group will be created for a server name. - When set to C(name) the name of a server will be used. When multiple servers share the same name, then the servers IDs will be used. - Default value of I(inventory_hostname) is opposite of the default value for option C(use_hostnames) in legacy openstack.py inventory script. type: string choices: ['name', 'uuid'] default: 'name' legacy_groups: description: - Automatically create groups from host variables. type: bool default: true only_clouds: description: - List of clouds in C(clouds.yaml) which will be contacted to use instead of using all clouds. type: list elements: str default: [] plugin: description: - Token which marks a given YAML configuration file as a valid input file for this inventory plugin. required: true choices: ['openstack', 'openstack.cloud.openstack'] private: description: - Use private interfaces of servers, if available, when determining ip addresses for Ansible hosts. - Using I(private) helps when running Ansible from a server in the cloud and one wants to ensure that servers communicate over private networks only. type: bool default: false show_all: description: - Whether all servers should be listed or not. - When I(show_all) is C(false) then only servers with a valid ip address, regardless it is private or public, will be listed. type: bool default: false use_names: description: - "When I(use_names) is C(false), its default value, then a server's first floating ip address will be used for both facts C(ansible_host) and C(ansible_ssh_host). When no floating ip address is attached to a server, then its first non-floating ip addresses is used instead. If no addresses are attached to a server, then both facts will not be defined." - "When I(use_names) is C(true), then the server name will be for both C(ansible_host) and C(ansible_ssh_host) facts. This is useful for jump or bastion hosts where each server name is actually a server's FQDN." type: bool default: false requirements: - "python >= 3.6" - "openstacksdk >= 1.0.0" extends_documentation_fragment: - inventory_cache - constructed ''' EXAMPLES = r''' # Create a file called openstack.yaml, add the following content and run # $> ansible-inventory --list -vvv -i openstack.yaml plugin: openstack.cloud.openstack all_projects: false expand_hostvars: true fail_on_errors: true only_clouds: - "devstack-admin" strict: true ''' import collections import sys from ansible.errors import AnsibleParserError from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable from ansible_collections.openstack.cloud.plugins.module_utils.openstack import ( ensure_compatibility ) try: import openstack HAS_SDK = True except ImportError: HAS_SDK = False class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): NAME = 'openstack.cloud.openstack' def parse(self, inventory, loader, path, cache=True): super(InventoryModule, self).parse(inventory, loader, path, cache=cache) if not HAS_SDK: raise AnsibleParserError( 'Could not import Python library openstacksdk') try: ensure_compatibility(openstack.version.__version__) except ImportError as e: raise AnsibleParserError( 'Incompatible openstacksdk library found: {0}'.format(e)) # Redirect logging to stderr so it does not mix with output, in # particular JSON output of ansible-inventory. # TODO: Integrate openstack's logging with Ansible's logging. if self.display.verbosity > 3: openstack.enable_logging(debug=True, stream=sys.stderr) else: openstack.enable_logging(stream=sys.stderr) config = self._read_config_data(path) if 'plugin' not in config and 'clouds' not in config: raise AnsibleParserError( "Invalid OpenStack inventory configuration file found," " missing 'plugin' and 'clouds' keys.") # TODO: It it wise to disregard a potential user configuration error? if 'clouds' in config: self.display.vvvv( 'Found combined plugin config and clouds config file.') servers = self._fetch_servers(path, cache) # determine inventory hostnames if self.get_option('inventory_hostname') == 'name': count = collections.Counter(s['name'] for s in servers) inventory = dict(((server['name'], server) if count[server['name']] == 1 else (server['id'], server)) for server in servers) else: # self.get_option('inventory_hostname') == 'uuid' inventory = dict((server['id'], server) for server in servers) # drop servers without addresses show_all = self.get_option('show_all') inventory = dict((k, v) for k, v in inventory.items() if show_all or v['addresses']) for hostname, server in inventory.items(): host_vars = self._generate_host_vars(hostname, server) self._add_host(hostname, host_vars) if self.get_option('legacy_groups'): for hostname, server in inventory.items(): for group in self._generate_legacy_groups(server): group_name = self.inventory.add_group(group) if group_name == hostname: self.display.vvvv( 'Same name for host {0} and group {1}' .format(hostname, group_name)) self.inventory.add_host(hostname, group_name) else: self.inventory.add_child(group_name, hostname) def _add_host(self, hostname, host_vars): # Ref.: https://docs.ansible.com/ansible/latest/dev_guide/ # developing_inventory.html#constructed-features self.inventory.add_host(hostname, group='all') for k, v in host_vars.items(): self.inventory.set_variable(hostname, k, v) strict = self.get_option('strict') self._set_composite_vars( self.get_option('compose'), host_vars, hostname, strict=True) self._add_host_to_composed_groups( self.get_option('groups'), host_vars, hostname, strict=strict) self._add_host_to_keyed_groups( self.get_option('keyed_groups'), host_vars, hostname, strict=strict) def _fetch_servers(self, path, cache): cache_key = self._get_cache_prefix(path) user_cache_setting = self.get_option('cache') attempt_to_read_cache = user_cache_setting and cache cache_needs_update = not cache and user_cache_setting servers = None if attempt_to_read_cache: self.display.vvvv('Reading OpenStack inventory cache key {0}' .format(cache_key)) try: servers = self._cache[cache_key] except KeyError: self.display.vvvv("OpenStack inventory cache not found") cache_needs_update = True if not attempt_to_read_cache or cache_needs_update: self.display.vvvv('Retrieving servers from Openstack clouds') clouds_yaml_path = self.get_option('clouds_yaml_path') config_files = ( openstack.config.loader.CONFIG_FILES + ([clouds_yaml_path] if clouds_yaml_path else [])) config = openstack.config.loader.OpenStackConfig( config_files=config_files) only_clouds = self.get_option('only_clouds', []) if only_clouds: if not isinstance(only_clouds, list): raise AnsibleParserError( 'Option only_clouds in OpenStack inventory' ' configuration is not a list') cloud_regions = [config.get_one(cloud=cloud) for cloud in only_clouds] else: cloud_regions = config.get_all() clouds = [openstack.connection.Connection(config=cloud_region) for cloud_region in cloud_regions] if self.get_option('private'): for cloud in self.clouds: cloud.private = True self.display.vvvv( 'Found {0} OpenStack cloud(s)' .format(len(clouds))) self.display.vvvv( 'Using {0} OpenStack cloud(s)' .format(len(clouds))) expand_hostvars = self.get_option('expand_hostvars') all_projects = self.get_option('all_projects') servers = [] def _expand_server(server, cloud, volumes): # calling openstacksdk's compute.servers() with # details=True already fetched most facts # cloud dict is used for legacy_groups option server['cloud'] = dict(name=cloud.name) region = cloud.config.get_region_name() if region: server['cloud']['region'] = region if not expand_hostvars: # do not query OpenStack API for additional data return server # TODO: Consider expanding 'flavor', 'image' and # 'security_groups' when users still require this # functionality. # Ref.: https://opendev.org/openstack/openstacksdk/src/commit/\ # 289e5c2d3cba0eb1c008988ae5dccab5be05d9b6/openstack/cloud/meta.py#L482 server['volumes'] = [v for v in volumes if any(a['server_id'] == server['id'] for a in v['attachments'])] return server for cloud in clouds: if expand_hostvars: volumes = [v.to_dict(computed=False) for v in cloud.block_storage.volumes()] else: volumes = [] try: for server in [ # convert to dict before expanding servers # to allow us to attach attributes _expand_server(server.to_dict(computed=False), cloud, volumes) for server in cloud.compute.servers( all_projects=all_projects, # details are required because 'addresses' # attribute must be populated details=True) ]: servers.append(server) except openstack.exceptions.OpenStackCloudException as e: self.display.warning( 'Fetching servers for cloud {0} failed with: {1}' .format(cloud.name, str(e))) if self.get_option('fail_on_errors'): raise if cache_needs_update: self._cache[cache_key] = servers return servers def _generate_host_vars(self, hostname, server): # populate host_vars with 'ansible_host', 'ansible_ssh_host' and # 'openstack' facts host_vars = dict(openstack=server) if self.get_option('use_names'): host_vars['ansible_ssh_host'] = server['name'] host_vars['ansible_host'] = server['name'] else: # flatten addresses dictionary addresses = [a for addresses in (server['addresses'] or {}).values() for a in addresses] floating_ip = next( (address['addr'] for address in addresses if address['OS-EXT-IPS:type'] == 'floating'), None) fixed_ip = next( (address['addr'] for address in addresses if address['OS-EXT-IPS:type'] == 'fixed'), None) ip = floating_ip if floating_ip is not None else fixed_ip if ip is not None: host_vars['ansible_ssh_host'] = ip host_vars['ansible_host'] = ip return host_vars def _generate_legacy_groups(self, server): groups = [] # cloud was added by _expand_server() cloud = server['cloud'] cloud_name = cloud['name'] groups.append(cloud_name) region = cloud['region'] if 'region' in cloud else None if region is not None: groups.append(region) groups.append('{cloud}_{region}'.format(cloud=cloud_name, region=region)) metadata = server.get('metadata', {}) if 'group' in metadata: groups.append(metadata['group']) for extra_group in metadata.get('groups', '').split(','): if extra_group: groups.append(extra_group.strip()) for k, v in metadata.items(): groups.append('meta-{k}_{v}'.format(k=k, v=v)) groups.append('instance-{id}'.format(id=server['id'])) for k in ('flavor', 'image'): if 'name' in server[k]: groups.append('{k}-{v}'.format(k=k, v=server[k]['name'])) availability_zone = server['availability_zone'] if availability_zone: groups.append(availability_zone) if region: groups.append( '{region}_{availability_zone}' .format(region=region, availability_zone=availability_zone)) groups.append( '{cloud}_{region}_{availability_zone}' .format(cloud=cloud_name, region=region, availability_zone=availability_zone)) return groups def verify_file(self, path): if super(InventoryModule, self).verify_file(path): for fn in ('openstack', 'clouds'): for suffix in ('yaml', 'yml'): maybe = '{fn}.{suffix}'.format(fn=fn, suffix=suffix) if path.endswith(maybe): self.display.vvvv( 'OpenStack inventory configuration file found:' ' {0}'.format(maybe)) return True return False