1#!/usr/bin/env python 2 3""" 4Cobbler external inventory script 5================================= 6 7Ansible has a feature where instead of reading from /usr/local/etc/ansible/hosts 8as a text file, it can query external programs to obtain the list 9of hosts, groups the hosts are in, and even variables to assign to each host. 10 11To use this, copy this file over /usr/local/etc/ansible/hosts and chmod +x the file. 12This, more or less, allows you to keep one central database containing 13info about all of your managed instances. 14 15This script is an example of sourcing that data from Cobbler 16(https://cobbler.github.io). With cobbler each --mgmt-class in cobbler 17will correspond to a group in Ansible, and --ks-meta variables will be 18passed down for use in templates or even in argument lines. 19 20NOTE: The cobbler system names will not be used. Make sure a 21cobbler --dns-name is set for each cobbler system. If a system 22appears with two DNS names we do not add it twice because we don't want 23ansible talking to it twice. The first one found will be used. If no 24--dns-name is set the system will NOT be visible to ansible. We do 25not add cobbler system names because there is no requirement in cobbler 26that those correspond to addresses. 27 28Tested with Cobbler 2.0.11. 29 30Changelog: 31 - 2015-06-21 dmccue: Modified to support run-once _meta retrieval, results in 32 higher performance at ansible startup. Groups are determined by owner rather than 33 default mgmt_classes. DNS name determined from hostname. cobbler values are written 34 to a 'cobbler' fact namespace 35 36 - 2013-09-01 pgehres: Refactored implementation to make use of caching and to 37 limit the number of connections to external cobbler server for performance. 38 Added use of cobbler.ini file to configure settings. Tested with Cobbler 2.4.0 39 40""" 41 42# (c) 2012, Michael DeHaan <michael.dehaan@gmail.com> 43# 44# This file is part of Ansible, 45# 46# Ansible is free software: you can redistribute it and/or modify 47# it under the terms of the GNU General Public License as published by 48# the Free Software Foundation, either version 3 of the License, or 49# (at your option) any later version. 50# 51# Ansible is distributed in the hope that it will be useful, 52# but WITHOUT ANY WARRANTY; without even the implied warranty of 53# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 54# GNU General Public License for more details. 55# 56# You should have received a copy of the GNU General Public License 57# along with Ansible. If not, see <https://www.gnu.org/licenses/>. 58 59###################################################################### 60 61import argparse 62import os 63import re 64from time import time 65import xmlrpclib 66 67import json 68 69from ansible.module_utils.six import iteritems 70from ansible.module_utils.six.moves import configparser as ConfigParser 71 72# NOTE -- this file assumes Ansible is being accessed FROM the cobbler 73# server, so it does not attempt to login with a username and password. 74# this will be addressed in a future version of this script. 75 76orderby_keyname = 'owners' # alternatively 'mgmt_classes' 77 78 79class CobblerInventory(object): 80 81 def __init__(self): 82 83 """ Main execution path """ 84 self.conn = None 85 86 self.inventory = dict() # A list of groups and the hosts in that group 87 self.cache = dict() # Details about hosts in the inventory 88 self.ignore_settings = False # used to only look at env vars for settings. 89 90 # Read env vars, read settings, and parse CLI arguments 91 self.parse_env_vars() 92 self.read_settings() 93 self.parse_cli_args() 94 95 # Cache 96 if self.args.refresh_cache: 97 self.update_cache() 98 elif not self.is_cache_valid(): 99 self.update_cache() 100 else: 101 self.load_inventory_from_cache() 102 self.load_cache_from_cache() 103 104 data_to_print = "" 105 106 # Data to print 107 if self.args.host: 108 data_to_print += self.get_host_info() 109 else: 110 self.inventory['_meta'] = {'hostvars': {}} 111 for hostname in self.cache: 112 self.inventory['_meta']['hostvars'][hostname] = {'cobbler': self.cache[hostname]} 113 data_to_print += self.json_format_dict(self.inventory, True) 114 115 print(data_to_print) 116 117 def _connect(self): 118 if not self.conn: 119 self.conn = xmlrpclib.Server(self.cobbler_host, allow_none=True) 120 self.token = None 121 if self.cobbler_username is not None: 122 self.token = self.conn.login(self.cobbler_username, self.cobbler_password) 123 124 def is_cache_valid(self): 125 """ Determines if the cache files have expired, or if it is still valid """ 126 127 if os.path.isfile(self.cache_path_cache): 128 mod_time = os.path.getmtime(self.cache_path_cache) 129 current_time = time() 130 if (mod_time + self.cache_max_age) > current_time: 131 if os.path.isfile(self.cache_path_inventory): 132 return True 133 134 return False 135 136 def read_settings(self): 137 """ Reads the settings from the cobbler.ini file """ 138 139 if(self.ignore_settings): 140 return 141 142 config = ConfigParser.SafeConfigParser() 143 config.read(os.path.dirname(os.path.realpath(__file__)) + '/cobbler.ini') 144 145 self.cobbler_host = config.get('cobbler', 'host') 146 self.cobbler_username = None 147 self.cobbler_password = None 148 if config.has_option('cobbler', 'username'): 149 self.cobbler_username = config.get('cobbler', 'username') 150 if config.has_option('cobbler', 'password'): 151 self.cobbler_password = config.get('cobbler', 'password') 152 153 # Cache related 154 cache_path = config.get('cobbler', 'cache_path') 155 self.cache_path_cache = cache_path + "/ansible-cobbler.cache" 156 self.cache_path_inventory = cache_path + "/ansible-cobbler.index" 157 self.cache_max_age = config.getint('cobbler', 'cache_max_age') 158 159 def parse_env_vars(self): 160 """ Reads the settings from the environment """ 161 162 # Env. Vars: 163 # COBBLER_host 164 # COBBLER_username 165 # COBBLER_password 166 # COBBLER_cache_path 167 # COBBLER_cache_max_age 168 # COBBLER_ignore_settings 169 170 self.cobbler_host = os.getenv('COBBLER_host', None) 171 self.cobbler_username = os.getenv('COBBLER_username', None) 172 self.cobbler_password = os.getenv('COBBLER_password', None) 173 174 # Cache related 175 cache_path = os.getenv('COBBLER_cache_path', None) 176 if(cache_path is not None): 177 self.cache_path_cache = cache_path + "/ansible-cobbler.cache" 178 self.cache_path_inventory = cache_path + "/ansible-cobbler.index" 179 180 self.cache_max_age = int(os.getenv('COBBLER_cache_max_age', "30")) 181 182 # ignore_settings is used to ignore the settings file, for use in Ansible 183 # Tower (or AWX inventory scripts and not throw python exceptions.) 184 if(os.getenv('COBBLER_ignore_settings', False) == "True"): 185 self.ignore_settings = True 186 187 def parse_cli_args(self): 188 """ Command line argument processing """ 189 190 parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on Cobbler') 191 parser.add_argument('--list', action='store_true', default=True, help='List instances (default: True)') 192 parser.add_argument('--host', action='store', help='Get all the variables about a specific instance') 193 parser.add_argument('--refresh-cache', action='store_true', default=False, 194 help='Force refresh of cache by making API requests to cobbler (default: False - use cache files)') 195 self.args = parser.parse_args() 196 197 def update_cache(self): 198 """ Make calls to cobbler and save the output in a cache """ 199 200 self._connect() 201 self.groups = dict() 202 self.hosts = dict() 203 if self.token is not None: 204 data = self.conn.get_systems(self.token) 205 else: 206 data = self.conn.get_systems() 207 208 for host in data: 209 # Get the FQDN for the host and add it to the right groups 210 dns_name = host['hostname'] # None 211 ksmeta = None 212 interfaces = host['interfaces'] 213 # hostname is often empty for non-static IP hosts 214 if dns_name == '': 215 for (iname, ivalue) in iteritems(interfaces): 216 if ivalue['management'] or not ivalue['static']: 217 this_dns_name = ivalue.get('dns_name', None) 218 if this_dns_name is not None and this_dns_name is not "": 219 dns_name = this_dns_name 220 221 if dns_name == '' or dns_name is None: 222 continue 223 224 status = host['status'] 225 profile = host['profile'] 226 classes = host[orderby_keyname] 227 228 if status not in self.inventory: 229 self.inventory[status] = [] 230 self.inventory[status].append(dns_name) 231 232 if profile not in self.inventory: 233 self.inventory[profile] = [] 234 self.inventory[profile].append(dns_name) 235 236 for cls in classes: 237 if cls not in self.inventory: 238 self.inventory[cls] = [] 239 self.inventory[cls].append(dns_name) 240 241 # Since we already have all of the data for the host, update the host details as well 242 243 # The old way was ksmeta only -- provide backwards compatibility 244 245 self.cache[dns_name] = host 246 if "ks_meta" in host: 247 for key, value in iteritems(host["ks_meta"]): 248 self.cache[dns_name][key] = value 249 250 self.write_to_cache(self.cache, self.cache_path_cache) 251 self.write_to_cache(self.inventory, self.cache_path_inventory) 252 253 def get_host_info(self): 254 """ Get variables about a specific host """ 255 256 if not self.cache or len(self.cache) == 0: 257 # Need to load index from cache 258 self.load_cache_from_cache() 259 260 if self.args.host not in self.cache: 261 # try updating the cache 262 self.update_cache() 263 264 if self.args.host not in self.cache: 265 # host might not exist anymore 266 return self.json_format_dict({}, True) 267 268 return self.json_format_dict(self.cache[self.args.host], True) 269 270 def push(self, my_dict, key, element): 271 """ Pushed an element onto an array that may not have been defined in the dict """ 272 273 if key in my_dict: 274 my_dict[key].append(element) 275 else: 276 my_dict[key] = [element] 277 278 def load_inventory_from_cache(self): 279 """ Reads the index from the cache file sets self.index """ 280 281 cache = open(self.cache_path_inventory, 'r') 282 json_inventory = cache.read() 283 self.inventory = json.loads(json_inventory) 284 285 def load_cache_from_cache(self): 286 """ Reads the cache from the cache file sets self.cache """ 287 288 cache = open(self.cache_path_cache, 'r') 289 json_cache = cache.read() 290 self.cache = json.loads(json_cache) 291 292 def write_to_cache(self, data, filename): 293 """ Writes data in JSON format to a file """ 294 json_data = self.json_format_dict(data, True) 295 cache = open(filename, 'w') 296 cache.write(json_data) 297 cache.close() 298 299 def to_safe(self, word): 300 """ Converts 'bad' characters in a string to underscores so they can be used as Ansible groups """ 301 302 return re.sub(r"[^A-Za-z0-9\-]", "_", word) 303 304 def json_format_dict(self, data, pretty=False): 305 """ Converts a dict to a JSON object and dumps it as a formatted string """ 306 307 if pretty: 308 return json.dumps(data, sort_keys=True, indent=2) 309 else: 310 return json.dumps(data) 311 312 313CobblerInventory() 314