aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--README.md53
-rw-r--r--foreman.ini15
-rwxr-xr-xforeman_ansible_inventory.py245
-rw-r--r--setup.py64
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"],
+)