diff options
-rw-r--r-- | .gitignore | 6 | ||||
-rw-r--r-- | README.md | 53 | ||||
-rw-r--r-- | foreman.ini | 15 | ||||
-rwxr-xr-x | foreman_ansible_inventory.py | 245 | ||||
-rw-r--r-- | setup.py | 64 |
5 files changed, 383 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3468d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*~ +*.index +*.cache +build/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c4408e --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +foreman_ansible_inventory +========================= + +This script can be used as an ansible dynamic inventory[1]. +The connection parameters are set up via a configuration +file *foreman.ini* that resides in the same dir as the +inventory script. + +The data returned from Foreman for each host is stored in a foreman +hash so they're available as *host_vars*. + +The hostgroup of each host is created as ansible group with +a foreman prefix, all lowercase and problematic parameters removed. So +e.g. the foreman hostgroup + + myapp / webtier / datacenter1 + +would turn into the ansible group: + + foreman_myapp_webtier_datacenter1 + +Furthermore groups can be created on the fly using the +*group_patterns* variable in *foreman.ini* so that you can build up +hierarchies using parameters on the hostgroup. + +Lets assume you have a host that is built using this nested hostgroup: + + myapp / webtier / datacenter1 + +and each of the hostgroups defines a parameters respectively: + + myapp: app_param = myapp + webtier: tier_param = webtier + datacenter1: dc_param = datacenter1 + +then *group_patterns* like: + + [ansible] + group_patterns = ["{app_param}-{tier_param}-{dc_param}", + "{app_param}-{tier_param}", + "{app_param}"] + +would put the host into the additional anisble groups: + + - myapp-webtier-datacenter1 + - myapp-webtier + - myapp + +by reursively resolving the hostgroups, getting the parameter keys and +values performing doing a Python *string.format()* like replacement on +it. + +[1]: http://docs.ansible.com/intro_dynamic_inventory.html diff --git a/foreman.ini b/foreman.ini new file mode 100644 index 0000000..d5cd56e --- /dev/null +++ b/foreman.ini @@ -0,0 +1,15 @@ +[foreman] +url = http://localhost:3000/ +user = foreman +password = secret +ssl_verify = True + +[ansible] +group_patterns = ["{app}-{tier}-{color}", + "{app}-{color}", + "{app}", + "{tier}"] + +[cache] +path = . +max_age = 60 diff --git a/foreman_ansible_inventory.py b/foreman_ansible_inventory.py new file mode 100755 index 0000000..03c2914 --- /dev/null +++ b/foreman_ansible_inventory.py @@ -0,0 +1,245 @@ +#!/usr/bin/python +# vim: set fileencoding=utf-8 : +# +# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org> +# +# This script is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with it. If not, see <http://www.gnu.org/licenses/>. +# +# This is somewhat based on cobbler inventory + +import argparse +import ConfigParser +import os +import re +from time import time +import requests +from requests.auth import HTTPBasicAuth + +try: + import json +except ImportError: + import simplejson as json + + +class ForemanInventory(object): + def __init__(self): + """ Main execution path """ + self.inventory = dict() # A list of groups and the hosts in that group + self.cache = dict() # Details about hosts in the inventory + self.hostgroups = dict() # host groups + + # Read settings and parse CLI arguments + self.read_settings() + self.parse_cli_args() + + # Cache + if self.args.refresh_cache: + self.update_cache() + elif not self.is_cache_valid(): + self.update_cache() + else: + self.load_inventory_from_cache() + self.load_cache_from_cache() + + data_to_print = "" + + # Data to print + if self.args.host: + data_to_print += self.get_host_info() + else: + self.inventory['_meta'] = {'hostvars': {}} + for hostname in self.cache: + self.inventory['_meta']['hostvars'][hostname] = {'foreman': self.cache[hostname] } + data_to_print += self.json_format_dict(self.inventory, True) + + print(data_to_print) + + def is_cache_valid(self): + """ Determines if the cache files have expired, or if it is still valid """ + + if os.path.isfile(self.cache_path_cache): + mod_time = os.path.getmtime(self.cache_path_cache) + current_time = time() + if (mod_time + self.cache_max_age) > current_time: + if os.path.isfile(self.cache_path_inventory): + return True + return False + + def read_settings(self): + """ Reads the settings from the foreman.ini file """ + + config = ConfigParser.SafeConfigParser() + config.read( + ["/etc/ansible/foreman.ini", + os.path.dirname(os.path.realpath(__file__)) + '/foreman.ini']) + + # Foreman API related + self.foreman_url = config.get('foreman', 'url') + self.foreman_user = config.get('foreman', 'user') + self.foreman_pw = config.get('foreman', 'password') + self.foreman_ssl_verify = config.getboolean('foreman', 'ssl_verify') + + # Ansible related + try: + group_patterns = config.get('ansible', 'group_patterns') + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + group_patterns = "[]" + + self.group_patterns = eval(group_patterns) + + # Cache related + try: + cache_path = config.get('cache', 'path') + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + cache_path = '.' + (script, ext) = os.path.splitext(os.path.basename(__file__)) + self.cache_path_cache = cache_path + "/%s.cache" % script + self.cache_path_inventory = cache_path + "/%s.index" % script + self.cache_max_age = config.getint('cache', 'max_age') + + def parse_cli_args(self): + """ Command line argument processing """ + + parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on foreman') + parser.add_argument('--list', action='store_true', default=True, help='List instances (default: True)') + parser.add_argument('--host', action='store', help='Get all the variables about a specific instance') + parser.add_argument('--refresh-cache', action='store_true', default=False, + help='Force refresh of cache by making API requests to foreman (default: False - use cache files)') + self.args = parser.parse_args() + + def _get_json(self, url): + return requests.get(url, + auth=HTTPBasicAuth(self.foreman_user, self.foreman_pw), + verify=self.foreman_ssl_verify).json() + + def _get_hosts(self): + return self._get_json("%s/api/v2/hosts" % self.foreman_url)['results'] + + def _get_hostgroup_by_id(self, hid): + if hid not in self.hostgroups: + url = "%s/api/v2/hostgroups/%s" % (self.foreman_url, hid) + self.hostgroups[hid] = self._get_json(url) + return self.hostgroups[hid] + + def _resolve_params(self, host): + """ + Resolve all host group params of the host using the top level + hostgroup and the ancestry. + """ + hid = host['hostgroup_id'] + if not hid: + return {} + + hostgroup = self._get_hostgroup_by_id(hid) + ancestry = hostgroup.get('ancestry', '').split('/') + # Append top level hostgroup last to overwrite lower + # level values + ancestry.append(hid) + params = {} + + for hid in ancestry: + for param in self._get_hostgroup_by_id(hid)['parameters']: + name = param['name'] + params[name] = param['value'] + + return params + + def update_cache(self): + """Make calls to foreman and save the output in a cache""" + + self.groups = dict() + self.hosts = dict() + + for host in self._get_hosts(): + dns_name = host['name'] + + hostgroup = host.get('hostgroup_name') + if hostgroup: + hkey = self.to_safe('foreman_' + hostgroup.lower()) + self.push(self.inventory, hkey, dns_name) + + # Ansible groups by parameters in host groups based + # on group_patterns in config + params = self._resolve_params(host) + for pattern in self.group_patterns: + try: + key = pattern.format(**params) + self.push(self.inventory, key, dns_name) + except KeyError: + pass # Host not part of this group + + self.cache[dns_name] = host + self.push(self.inventory, 'all', dns_name) + + self.write_to_cache(self.cache, self.cache_path_cache) + self.write_to_cache(self.inventory, self.cache_path_inventory) + + def get_host_info(self): + """ Get variables about a specific host """ + + if not self.cache or len(self.cache) == 0: + # Need to load index from cache + self.load_cache_from_cache() + + if self.args.host not in self.cache: + # try updating the cache + self.update_cache() + + if self.args.host not in self.cache: + # host might not exist anymore + return self.json_format_dict({}, True) + + return self.json_format_dict(self.cache[self.args.host], True) + + def push(self, d, k, v): + if k in d: + d[k].append(v) + else: + d[k] = [v] + + def load_inventory_from_cache(self): + """ Reads the index from the cache file sets self.index """ + + cache = open(self.cache_path_inventory, 'r') + json_inventory = cache.read() + self.inventory = json.loads(json_inventory) + + def load_cache_from_cache(self): + """ Reads the cache from the cache file sets self.cache """ + + cache = open(self.cache_path_cache, 'r') + json_cache = cache.read() + self.cache = json.loads(json_cache) + + def write_to_cache(self, data, filename): + """ Writes data in JSON format to a file """ + json_data = self.json_format_dict(data, True) + cache = open(filename, 'w') + cache.write(json_data) + cache.close() + + def to_safe(self, word): + ''' Converts 'bad' characters in a string to underscores so they can be used as Ansible groups ''' + regex = "[^A-Za-z0-9\_]" + return re.sub(regex, "_", word.replace(" ", "")) + + def json_format_dict(self, data, pretty=False): + """ Converts a dict to a JSON object and dumps it as a formatted string """ + + if pretty: + return json.dumps(data, sort_keys=True, indent=2) + else: + return json.dumps(data) + +ForemanInventory() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1a1d9f3 --- /dev/null +++ b/setup.py @@ -0,0 +1,64 @@ +#!/usr/bin/python +# vim: set fileencoding=utf-8 : +# +# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with it. If not, see <http://www.gnu.org/licenses/>. +# +# END OF COPYRIGHT # + +import subprocess +from setuptools import setup, find_packages +import os + + +def fetch_version(): + """Get version from debian changelog and write it to gbp/version.py""" + version = "0.0" + + try: + popen = subprocess.Popen('dpkg-parsechangelog', stdout=subprocess.PIPE) + out, ret = popen.communicate() + for line in out.decode('utf-8').split('\n'): + if line.startswith('Version:'): + version = line.split(' ')[1].strip() + break + except OSError: + pass # Failing is fine, we just can't print the version then + + with open('gbp/version.py', 'w') as f: + f.write('"The current gbp version number"\n') + f.write('gbp_version="%s"\n' % version) + + return version + + +def readme(): + with open('README') as file: + return file.read() + +setup(name = "foreman_ansible_inventory", + version = "0.0.1", + author = u'Guido Günther', + author_email = 'agx@sigxcpu.org', + description = 'Ansible dynamic inventory that queries the Foreman', + license = 'GPLv3+', + classifiers = [ + 'Environment :: Console', + 'Programming Language :: Python :: 2', + 'Operating System :: POSIX :: Linux', + ], + scripts = ['foreman_ansible_inventory.py'], + requires = ["requests"], +) |