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