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