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