1# Copyright (c) 2012, Marco Vito Moscaritolo <marco@agavee.com> 2# Copyright (c) 2013, Jesse Keating <jesse.keating@rackspace.com> 3# Copyright (c) 2015, Hewlett-Packard Development Company, L.P. 4# Copyright (c) 2016, Rackspace Australia 5# Copyright (c) 2017 Ansible Project 6# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 7 8 9DOCUMENTATION = ''' 10--- 11name: openstack 12plugin_type: inventory 13author: OpenStack Ansible SIG 14short_description: OpenStack inventory source 15requirements: 16 - "openstacksdk >= 0.28" 17description: 18 - Get inventory hosts from OpenStack clouds 19 - Uses openstack.(yml|yaml) YAML configuration file to configure the inventory plugin 20 - Uses standard clouds.yaml YAML configuration file to configure cloud credentials 21options: 22 plugin: 23 description: token that ensures this is a source file for the 'openstack' plugin. 24 required: True 25 choices: ['openstack', 'openstack.cloud.openstack'] 26 show_all: 27 description: toggles showing all vms vs only those with a working IP 28 type: bool 29 default: 'no' 30 inventory_hostname: 31 description: | 32 What to register as the inventory hostname. 33 If set to 'uuid' the uuid of the server will be used and a 34 group will be created for the server name. 35 If set to 'name' the name of the server will be used unless 36 there are more than one server with the same name in which 37 case the 'uuid' logic will be used. 38 Default is to do 'name', which is the opposite of the old 39 openstack.py inventory script's option use_hostnames) 40 type: string 41 choices: 42 - name 43 - uuid 44 default: "name" 45 expand_hostvars: 46 description: | 47 Run extra commands on each host to fill in additional 48 information about the host. May interrogate cinder and 49 neutron and can be expensive for people with many hosts. 50 (Note, the default value of this is opposite from the default 51 old openstack.py inventory script's option expand_hostvars) 52 type: bool 53 default: 'no' 54 private: 55 description: | 56 Use the private interface of each server, if it has one, as 57 the host's IP in the inventory. This can be useful if you are 58 running ansible inside a server in the cloud and would rather 59 communicate to your servers over the private network. 60 type: bool 61 default: 'no' 62 only_clouds: 63 description: | 64 List of clouds from clouds.yaml to use, instead of using 65 the whole list. 66 type: list 67 default: [] 68 fail_on_errors: 69 description: | 70 Causes the inventory to fail and return no hosts if one cloud 71 has failed (for example, bad credentials or being offline). 72 When set to False, the inventory will return as many hosts as 73 it can from as many clouds as it can contact. (Note, the 74 default value of this is opposite from the old openstack.py 75 inventory script's option fail_on_errors) 76 type: bool 77 default: 'no' 78 all_projects: 79 description: | 80 Lists servers from all projects 81 type: bool 82 default: 'no' 83 clouds_yaml_path: 84 description: | 85 Override path to clouds.yaml file. If this value is given it 86 will be searched first. The default path for the 87 ansible inventory adds /usr/local/etc/ansible/openstack.yaml and 88 /usr/local/etc/ansible/openstack.yml to the regular locations documented 89 at https://docs.openstack.org/os-client-config/latest/user/configuration.html#config-files 90 type: list 91 env: 92 - name: OS_CLIENT_CONFIG_FILE 93 compose: 94 description: Create vars from jinja2 expressions. 95 type: dictionary 96 default: {} 97 groups: 98 description: Add hosts to group based on Jinja2 conditionals. 99 type: dictionary 100 default: {} 101 legacy_groups: 102 description: Automatically create groups from host variables. 103 type: bool 104 default: true 105 106extends_documentation_fragment: 107- inventory_cache 108- constructed 109 110''' 111 112EXAMPLES = ''' 113# file must be named openstack.yaml or openstack.yml 114# Make the plugin behave like the default behavior of the old script 115plugin: openstack.cloud.openstack 116expand_hostvars: yes 117fail_on_errors: yes 118all_projects: yes 119''' 120 121import collections 122import sys 123import logging 124 125from ansible.errors import AnsibleParserError 126from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable 127from ansible.utils.display import Display 128 129display = Display() 130os_logger = logging.getLogger("openstack") 131 132try: 133 # Due to the name shadowing we should import other way 134 import importlib 135 sdk = importlib.import_module('openstack') 136 sdk_inventory = importlib.import_module('openstack.cloud.inventory') 137 client_config = importlib.import_module('openstack.config.loader') 138 sdk_exceptions = importlib.import_module("openstack.exceptions") 139 HAS_SDK = True 140except ImportError: 141 display.vvvv("Couldn't import Openstack SDK modules") 142 HAS_SDK = False 143 144 145class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): 146 ''' Host inventory provider for ansible using OpenStack clouds. ''' 147 148 NAME = 'openstack.cloud.openstack' 149 150 def parse(self, inventory, loader, path, cache=True): 151 152 super(InventoryModule, self).parse(inventory, loader, path) 153 154 cache_key = self._get_cache_prefix(path) 155 156 # file is config file 157 self._config_data = self._read_config_data(path) 158 159 msg = '' 160 if not self._config_data: 161 msg = 'File empty. this is not my config file' 162 elif 'plugin' in self._config_data and self._config_data['plugin'] not in (self.NAME, 'openstack'): 163 msg = 'plugin config file, but not for us: %s' % self._config_data['plugin'] 164 elif 'plugin' not in self._config_data and 'clouds' not in self._config_data: 165 msg = "it's not a plugin configuration nor a clouds.yaml file" 166 elif not HAS_SDK: 167 msg = "openstacksdk is required for the OpenStack inventory plugin. OpenStack inventory sources will be skipped." 168 169 if msg: 170 display.vvvv(msg) 171 raise AnsibleParserError(msg) 172 173 if 'clouds' in self._config_data: 174 self.display.vvvv( 175 "Found clouds config file instead of plugin config. " 176 "Using default configuration." 177 ) 178 self._config_data = {} 179 180 # update cache if the user has caching enabled and the cache is being refreshed 181 # will update variable below in the case of an expired cache 182 cache_needs_update = not cache and self.get_option('cache') 183 184 if cache: 185 cache = self.get_option('cache') 186 source_data = None 187 if cache: 188 self.display.vvvv("Reading inventory data from cache: %s" % cache_key) 189 try: 190 source_data = self._cache[cache_key] 191 except KeyError: 192 # cache expired or doesn't exist yet 193 display.vvvv("Inventory data cache not found") 194 cache_needs_update = True 195 196 if not source_data: 197 self.display.vvvv("Getting hosts from Openstack clouds") 198 clouds_yaml_path = self._config_data.get('clouds_yaml_path') 199 if clouds_yaml_path: 200 config_files = ( 201 clouds_yaml_path 202 + client_config.CONFIG_FILES 203 ) 204 else: 205 config_files = None 206 207 # Redict logging to stderr so it does not mix with output 208 # particular ansible-inventory JSON output 209 # TODO(mordred) Integrate openstack's logging with ansible's logging 210 if self.display.verbosity > 3: 211 sdk.enable_logging(debug=True, stream=sys.stderr) 212 else: 213 sdk.enable_logging(stream=sys.stderr) 214 215 cloud_inventory = sdk_inventory.OpenStackInventory( 216 config_files=config_files, 217 private=self._config_data.get('private', False)) 218 self.display.vvvv("Found %d cloud(s) in Openstack" % 219 len(cloud_inventory.clouds)) 220 only_clouds = self._config_data.get('only_clouds', []) 221 if only_clouds and not isinstance(only_clouds, list): 222 raise ValueError( 223 'OpenStack Inventory Config Error: only_clouds must be' 224 ' a list') 225 if only_clouds: 226 new_clouds = [] 227 for cloud in cloud_inventory.clouds: 228 self.display.vvvv("Looking at cloud : %s" % cloud.name) 229 if cloud.name in only_clouds: 230 self.display.vvvv("Selecting cloud : %s" % cloud.name) 231 new_clouds.append(cloud) 232 cloud_inventory.clouds = new_clouds 233 234 self.display.vvvv("Selected %d cloud(s)" % 235 len(cloud_inventory.clouds)) 236 237 expand_hostvars = self._config_data.get('expand_hostvars', False) 238 fail_on_errors = self._config_data.get('fail_on_errors', False) 239 all_projects = self._config_data.get('all_projects', False) 240 241 source_data = [] 242 try: 243 source_data = cloud_inventory.list_hosts( 244 expand=expand_hostvars, fail_on_cloud_config=fail_on_errors, 245 all_projects=all_projects) 246 except Exception as e: 247 self.display.warning("Couldn't list Openstack hosts. " 248 "See logs for details") 249 os_logger.error(e.message) 250 finally: 251 if cache_needs_update: 252 self._cache[cache_key] = source_data 253 254 self._populate_from_source(source_data) 255 256 def _populate_from_source(self, source_data): 257 groups = collections.defaultdict(list) 258 firstpass = collections.defaultdict(list) 259 hostvars = {} 260 261 use_server_id = ( 262 self._config_data.get('inventory_hostname', 'name') != 'name') 263 show_all = self._config_data.get('show_all', False) 264 265 for server in source_data: 266 if 'interface_ip' not in server and not show_all: 267 continue 268 firstpass[server['name']].append(server) 269 270 for name, servers in firstpass.items(): 271 if len(servers) == 1 and not use_server_id: 272 self._append_hostvars(hostvars, groups, name, servers[0]) 273 else: 274 server_ids = set() 275 # Trap for duplicate results 276 for server in servers: 277 server_ids.add(server['id']) 278 if len(server_ids) == 1 and not use_server_id: 279 self._append_hostvars(hostvars, groups, name, servers[0]) 280 else: 281 for server in servers: 282 self._append_hostvars( 283 hostvars, groups, server['id'], server, 284 namegroup=True) 285 286 self._set_variables(hostvars, groups) 287 288 def _set_variables(self, hostvars, groups): 289 290 strict = self.get_option('strict') 291 292 # set vars in inventory from hostvars 293 for host in hostvars: 294 295 # actually update inventory 296 for key in hostvars[host]: 297 self.inventory.set_variable(host, key, hostvars[host][key]) 298 299 # create composite vars 300 self._set_composite_vars( 301 self._config_data.get('compose'), self.inventory.get_host(host).get_vars(), host, strict) 302 303 # constructed groups based on conditionals 304 self._add_host_to_composed_groups( 305 self._config_data.get('groups'), hostvars[host], host, strict) 306 307 # constructed groups based on jinja expressions 308 self._add_host_to_keyed_groups( 309 self._config_data.get('keyed_groups'), hostvars[host], host, strict) 310 311 for group_name, group_hosts in groups.items(): 312 gname = self.inventory.add_group(group_name) 313 for host in group_hosts: 314 self.inventory.add_child(gname, host) 315 316 def _get_groups_from_server(self, server_vars, namegroup=True): 317 groups = [] 318 319 region = server_vars['region'] 320 cloud = server_vars['cloud'] 321 metadata = server_vars.get('metadata', {}) 322 323 # Create a group for the cloud 324 groups.append(cloud) 325 326 # Create a group on region 327 if region: 328 groups.append(region) 329 330 # And one by cloud_region 331 groups.append("%s_%s" % (cloud, region)) 332 333 # Check if group metadata key in servers' metadata 334 if 'group' in metadata: 335 groups.append(metadata['group']) 336 337 for extra_group in metadata.get('groups', '').split(','): 338 if extra_group: 339 groups.append(extra_group.strip()) 340 341 groups.append('instance-%s' % server_vars['id']) 342 if namegroup: 343 groups.append(server_vars['name']) 344 345 for key in ('flavor', 'image'): 346 if 'name' in server_vars[key]: 347 groups.append('%s-%s' % (key, server_vars[key]['name'])) 348 349 for key, value in iter(metadata.items()): 350 groups.append('meta-%s_%s' % (key, value)) 351 352 az = server_vars.get('az', None) 353 if az: 354 # Make groups for az, region_az and cloud_region_az 355 groups.append(az) 356 groups.append('%s_%s' % (region, az)) 357 groups.append('%s_%s_%s' % (cloud, region, az)) 358 return groups 359 360 def _append_hostvars(self, hostvars, groups, current_host, 361 server, namegroup=False): 362 hostvars[current_host] = dict( 363 ansible_ssh_host=server['interface_ip'], 364 ansible_host=server['interface_ip'], 365 openstack=server) 366 self.inventory.add_host(current_host) 367 368 if self.get_option('legacy_groups'): 369 for group in self._get_groups_from_server(server, namegroup=namegroup): 370 groups[group].append(current_host) 371 372 def verify_file(self, path): 373 374 if super(InventoryModule, self).verify_file(path): 375 for fn in ('openstack', 'clouds'): 376 for suffix in ('yaml', 'yml'): 377 maybe = '{fn}.{suffix}'.format(fn=fn, suffix=suffix) 378 if path.endswith(maybe): 379 self.display.vvvv("Valid plugin config file found") 380 return True 381 return False 382