1#!/usr/bin/env python
2# vim: set fileencoding=utf-8 :
3#
4# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org>,
5#                    Daniel Lobato Garcia <dlobatog@redhat.com>
6#
7# This script is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# Ansible is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with it.  If not, see <http://www.gnu.org/licenses/>.
19#
20# This is somewhat based on cobbler inventory
21
22# Stdlib imports
23# __future__ imports must occur at the beginning of file
24from __future__ import print_function
25import json
26import argparse
27import copy
28import os
29import re
30import sys
31from time import time
32from collections import defaultdict
33from distutils.version import LooseVersion, StrictVersion
34
35# 3rd party imports
36import requests
37if LooseVersion(requests.__version__) < LooseVersion('1.1.0'):
38    print('This script requires python-requests 1.1 as a minimum version')
39    sys.exit(1)
40
41from requests.auth import HTTPBasicAuth
42
43from ansible.module_utils._text import to_text
44from ansible.module_utils.six.moves import configparser as ConfigParser
45
46
47def json_format_dict(data, pretty=False):
48    """Converts a dict to a JSON object and dumps it as a formatted string"""
49
50    if pretty:
51        return json.dumps(data, sort_keys=True, indent=2)
52    else:
53        return json.dumps(data)
54
55
56class ForemanInventory(object):
57
58    def __init__(self):
59        self.inventory = defaultdict(list)  # A list of groups and the hosts in that group
60        self.cache = dict()   # Details about hosts in the inventory
61        self.params = dict()  # Params of each host
62        self.facts = dict()   # Facts of each host
63        self.hostgroups = dict()  # host groups
64        self.hostcollections = dict()  # host collections
65        self.session = None   # Requests session
66        self.config_paths = [
67            "/usr/local/etc/ansible/foreman.ini",
68            os.path.dirname(os.path.realpath(__file__)) + '/foreman.ini',
69        ]
70        env_value = os.environ.get('FOREMAN_INI_PATH')
71        if env_value is not None:
72            self.config_paths.append(os.path.expanduser(os.path.expandvars(env_value)))
73
74    def read_settings(self):
75        """Reads the settings from the foreman.ini file"""
76
77        config = ConfigParser.SafeConfigParser()
78        config.read(self.config_paths)
79
80        # Foreman API related
81        try:
82            self.foreman_url = config.get('foreman', 'url')
83            self.foreman_user = config.get('foreman', 'user')
84            self.foreman_pw = config.get('foreman', 'password', raw=True)
85            self.foreman_ssl_verify = config.getboolean('foreman', 'ssl_verify')
86        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError) as e:
87            print("Error parsing configuration: %s" % e, file=sys.stderr)
88            return False
89
90        # Ansible related
91        try:
92            group_patterns = config.get('ansible', 'group_patterns')
93        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
94            group_patterns = "[]"
95
96        self.group_patterns = json.loads(group_patterns)
97
98        try:
99            self.group_prefix = config.get('ansible', 'group_prefix')
100        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
101            self.group_prefix = "foreman_"
102
103        try:
104            self.want_facts = config.getboolean('ansible', 'want_facts')
105        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
106            self.want_facts = True
107
108        try:
109            self.want_hostcollections = config.getboolean('ansible', 'want_hostcollections')
110        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
111            self.want_hostcollections = False
112
113        try:
114            self.want_ansible_ssh_host = config.getboolean('ansible', 'want_ansible_ssh_host')
115        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
116            self.want_ansible_ssh_host = False
117
118        # Do we want parameters to be interpreted if possible as JSON? (no by default)
119        try:
120            self.rich_params = config.getboolean('ansible', 'rich_params')
121        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
122            self.rich_params = False
123
124        try:
125            self.host_filters = config.get('foreman', 'host_filters')
126        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
127            self.host_filters = None
128
129        # Cache related
130        try:
131            cache_path = os.path.expanduser(config.get('cache', 'path'))
132        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
133            cache_path = '.'
134        (script, ext) = os.path.splitext(os.path.basename(__file__))
135        self.cache_path_cache = cache_path + "/%s.cache" % script
136        self.cache_path_inventory = cache_path + "/%s.index" % script
137        self.cache_path_params = cache_path + "/%s.params" % script
138        self.cache_path_facts = cache_path + "/%s.facts" % script
139        self.cache_path_hostcollections = cache_path + "/%s.hostcollections" % script
140        try:
141            self.cache_max_age = config.getint('cache', 'max_age')
142        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
143            self.cache_max_age = 60
144        try:
145            self.scan_new_hosts = config.getboolean('cache', 'scan_new_hosts')
146        except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
147            self.scan_new_hosts = False
148
149        return True
150
151    def parse_cli_args(self):
152        """Command line argument processing"""
153
154        parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on foreman')
155        parser.add_argument('--list', action='store_true', default=True, help='List instances (default: True)')
156        parser.add_argument('--host', action='store', help='Get all the variables about a specific instance')
157        parser.add_argument('--refresh-cache', action='store_true', default=False,
158                            help='Force refresh of cache by making API requests to foreman (default: False - use cache files)')
159        self.args = parser.parse_args()
160
161    def _get_session(self):
162        if not self.session:
163            self.session = requests.session()
164            self.session.auth = HTTPBasicAuth(self.foreman_user, self.foreman_pw)
165            self.session.verify = self.foreman_ssl_verify
166        return self.session
167
168    def _get_json(self, url, ignore_errors=None, params=None):
169        if params is None:
170            params = {}
171        params['per_page'] = 250
172
173        page = 1
174        results = []
175        s = self._get_session()
176        while True:
177            params['page'] = page
178            ret = s.get(url, params=params)
179            if ignore_errors and ret.status_code in ignore_errors:
180                break
181            ret.raise_for_status()
182            json = ret.json()
183            # /hosts/:id has not results key
184            if 'results' not in json:
185                return json
186            # Facts are returned as dict in results not list
187            if isinstance(json['results'], dict):
188                return json['results']
189            # List of all hosts is returned paginaged
190            results = results + json['results']
191            if len(results) >= json['subtotal']:
192                break
193            page += 1
194            if len(json['results']) == 0:
195                print("Did not make any progress during loop. "
196                      "expected %d got %d" % (json['total'], len(results)),
197                      file=sys.stderr)
198                break
199        return results
200
201    def _get_hosts(self):
202        url = "%s/api/v2/hosts" % self.foreman_url
203
204        params = {}
205        if self.host_filters:
206            params['search'] = self.host_filters
207
208        return self._get_json(url, params=params)
209
210    def _get_host_data_by_id(self, hid):
211        url = "%s/api/v2/hosts/%s" % (self.foreman_url, hid)
212        return self._get_json(url)
213
214    def _get_facts_by_id(self, hid):
215        url = "%s/api/v2/hosts/%s/facts" % (self.foreman_url, hid)
216        return self._get_json(url)
217
218    def _resolve_params(self, host_params):
219        """Convert host params to dict"""
220        params = {}
221
222        for param in host_params:
223            name = param['name']
224            if self.rich_params:
225                try:
226                    params[name] = json.loads(param['value'])
227                except ValueError:
228                    params[name] = param['value']
229            else:
230                params[name] = param['value']
231
232        return params
233
234    def _get_facts(self, host):
235        """Fetch all host facts of the host"""
236        if not self.want_facts:
237            return {}
238
239        ret = self._get_facts_by_id(host['id'])
240        if len(ret.values()) == 0:
241            facts = {}
242        elif len(ret.values()) == 1:
243            facts = list(ret.values())[0]
244        else:
245            raise ValueError("More than one set of facts returned for '%s'" % host)
246        return facts
247
248    def write_to_cache(self, data, filename):
249        """Write data in JSON format to a file"""
250        json_data = json_format_dict(data, True)
251        cache = open(filename, 'w')
252        cache.write(json_data)
253        cache.close()
254
255    def _write_cache(self):
256        self.write_to_cache(self.cache, self.cache_path_cache)
257        self.write_to_cache(self.inventory, self.cache_path_inventory)
258        self.write_to_cache(self.params, self.cache_path_params)
259        self.write_to_cache(self.facts, self.cache_path_facts)
260        self.write_to_cache(self.hostcollections, self.cache_path_hostcollections)
261
262    def to_safe(self, word):
263        '''Converts 'bad' characters in a string to underscores
264        so they can be used as Ansible groups
265
266        >>> ForemanInventory.to_safe("foo-bar baz")
267        'foo_barbaz'
268        '''
269        regex = r"[^A-Za-z0-9\_]"
270        return re.sub(regex, "_", word.replace(" ", ""))
271
272    def update_cache(self, scan_only_new_hosts=False):
273        """Make calls to foreman and save the output in a cache"""
274
275        self.groups = dict()
276        self.hosts = dict()
277
278        for host in self._get_hosts():
279            if host['name'] in self.cache.keys() and scan_only_new_hosts:
280                continue
281            dns_name = host['name']
282
283            host_data = self._get_host_data_by_id(host['id'])
284            host_params = host_data.get('all_parameters', {})
285
286            # Create ansible groups for hostgroup
287            group = 'hostgroup'
288            val = host.get('%s_title' % group) or host.get('%s_name' % group)
289            if val:
290                safe_key = self.to_safe('%s%s_%s' % (
291                    to_text(self.group_prefix),
292                    group,
293                    to_text(val).lower()
294                ))
295                self.inventory[safe_key].append(dns_name)
296
297            # Create ansible groups for environment, location and organization
298            for group in ['environment', 'location', 'organization']:
299                val = host.get('%s_name' % group)
300                if val:
301                    safe_key = self.to_safe('%s%s_%s' % (
302                        to_text(self.group_prefix),
303                        group,
304                        to_text(val).lower()
305                    ))
306                    self.inventory[safe_key].append(dns_name)
307
308            for group in ['lifecycle_environment', 'content_view']:
309                val = host.get('content_facet_attributes', {}).get('%s_name' % group)
310                if val:
311                    safe_key = self.to_safe('%s%s_%s' % (
312                        to_text(self.group_prefix),
313                        group,
314                        to_text(val).lower()
315                    ))
316                    self.inventory[safe_key].append(dns_name)
317
318            params = self._resolve_params(host_params)
319
320            # Ansible groups by parameters in host groups and Foreman host
321            # attributes.
322            groupby = dict()
323            for k, v in params.items():
324                groupby[k] = self.to_safe(to_text(v))
325
326            # The name of the ansible groups is given by group_patterns:
327            for pattern in self.group_patterns:
328                try:
329                    key = pattern.format(**groupby)
330                    self.inventory[key].append(dns_name)
331                except KeyError:
332                    pass  # Host not part of this group
333
334            if self.want_hostcollections:
335                hostcollections = host_data.get('host_collections')
336
337                if hostcollections:
338                    # Create Ansible groups for host collections
339                    for hostcollection in hostcollections:
340                        safe_key = self.to_safe('%shostcollection_%s' % (self.group_prefix, hostcollection['name'].lower()))
341                        self.inventory[safe_key].append(dns_name)
342
343                self.hostcollections[dns_name] = hostcollections
344
345            self.cache[dns_name] = host
346            self.params[dns_name] = params
347            self.facts[dns_name] = self._get_facts(host)
348            self.inventory['all'].append(dns_name)
349        self._write_cache()
350
351    def is_cache_valid(self):
352        """Determines if the cache is still valid"""
353        if os.path.isfile(self.cache_path_cache):
354            mod_time = os.path.getmtime(self.cache_path_cache)
355            current_time = time()
356            if (mod_time + self.cache_max_age) > current_time:
357                if (os.path.isfile(self.cache_path_inventory) and
358                    os.path.isfile(self.cache_path_params) and
359                        os.path.isfile(self.cache_path_facts)):
360                    return True
361        return False
362
363    def load_inventory_from_cache(self):
364        """Read the index from the cache file sets self.index"""
365
366        with open(self.cache_path_inventory, 'r') as fp:
367            self.inventory = json.load(fp)
368
369    def load_params_from_cache(self):
370        """Read the index from the cache file sets self.index"""
371
372        with open(self.cache_path_params, 'r') as fp:
373            self.params = json.load(fp)
374
375    def load_facts_from_cache(self):
376        """Read the index from the cache file sets self.facts"""
377
378        if not self.want_facts:
379            return
380        with open(self.cache_path_facts, 'r') as fp:
381            self.facts = json.load(fp)
382
383    def load_hostcollections_from_cache(self):
384        """Read the index from the cache file sets self.hostcollections"""
385
386        if not self.want_hostcollections:
387            return
388        with open(self.cache_path_hostcollections, 'r') as fp:
389            self.hostcollections = json.load(fp)
390
391    def load_cache_from_cache(self):
392        """Read the cache from the cache file sets self.cache"""
393
394        with open(self.cache_path_cache, 'r') as fp:
395            self.cache = json.load(fp)
396
397    def get_inventory(self):
398        if self.args.refresh_cache or not self.is_cache_valid():
399            self.update_cache()
400        else:
401            self.load_inventory_from_cache()
402            self.load_params_from_cache()
403            self.load_facts_from_cache()
404            self.load_hostcollections_from_cache()
405            self.load_cache_from_cache()
406            if self.scan_new_hosts:
407                self.update_cache(True)
408
409    def get_host_info(self):
410        """Get variables about a specific host"""
411
412        if not self.cache or len(self.cache) == 0:
413            # Need to load index from cache
414            self.load_cache_from_cache()
415
416        if self.args.host not in self.cache:
417            # try updating the cache
418            self.update_cache()
419
420            if self.args.host not in self.cache:
421                # host might not exist anymore
422                return json_format_dict({}, True)
423
424        return json_format_dict(self.cache[self.args.host], True)
425
426    def _print_data(self):
427        data_to_print = ""
428        if self.args.host:
429            data_to_print += self.get_host_info()
430        else:
431            self.inventory['_meta'] = {'hostvars': {}}
432            for hostname in self.cache:
433                self.inventory['_meta']['hostvars'][hostname] = {
434                    'foreman': self.cache[hostname],
435                    'foreman_params': self.params[hostname],
436                }
437                if self.want_ansible_ssh_host and 'ip' in self.cache[hostname]:
438                    self.inventory['_meta']['hostvars'][hostname]['ansible_ssh_host'] = self.cache[hostname]['ip']
439                if self.want_facts:
440                    self.inventory['_meta']['hostvars'][hostname]['foreman_facts'] = self.facts[hostname]
441
442            data_to_print += json_format_dict(self.inventory, True)
443
444        print(data_to_print)
445
446    def run(self):
447        # Read settings and parse CLI arguments
448        if not self.read_settings():
449            return False
450        self.parse_cli_args()
451        self.get_inventory()
452        self._print_data()
453        return True
454
455
456if __name__ == '__main__':
457    sys.exit(not ForemanInventory().run())
458