diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 9c8c00b8f9..faadf12adb 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -3,6 +3,7 @@ # Python import cStringIO +import distutils.version import json import logging import os @@ -23,6 +24,7 @@ from django.conf import settings # AWX from awx.main.models import Job, ProjectUpdate +from awx.main.utils import get_ansible_version __all__ = ['RunJob', 'RunProjectUpdate'] @@ -228,6 +230,16 @@ class RunJob(BaseTask): env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_dir env['REST_API_URL'] = settings.INTERNAL_API_URL env['REST_API_TOKEN'] = job.task_auth_token or '' + + # When using Ansible >= 1.3, allow the inventory script to include host + # variables inline via ['_meta']['hostvars']. + try: + Version = distutils.version.StrictVersion + if Version( get_ansible_version()) >= Version('1.3'): + env['INVENTORY_HOSTVARS'] = str(True) + except ValueError: + pass + return env def build_args(self, job, **kwargs): diff --git a/awx/main/templates/main/inventory_script_view.md b/awx/main/templates/main/inventory_script_view.md index 2e4a1a5e8d..cf342d2512 100644 --- a/awx/main/templates/main/inventory_script_view.md +++ b/awx/main/templates/main/inventory_script_view.md @@ -1,10 +1,25 @@ Generate inventory group and host data as needed for an inventory script. +Refer to [External Inventory Scripts](http://www.ansibleworks.com/docs/api.html#external-inventory-scripts) +for more information on inventory scripts. + +## List Response + Make a GET request to this resource without query parameters to retrieve a JSON object containing groups, including the hosts, children and variables for each group. The response data is equivalent to that returned by passing the `--list` argument to an inventory script. +_(New in AWX 1.3)_ Specify a query string of `?hostvars=1` to retrieve the JSON +object above including all host variables. The `['_meta']['hostvars']` object +in the response contains an entry for each host with its variables. This +response format can be used with Ansible 1.3 and later to avoid making a +separate API request for each host. Refer to +[Tuning the External Inventory Script](http://www.ansibleworks.com/docs/api.html#tuning-the-external-inventory-script) +for more information on this feature. + +## Host Response + Make a GET request to this resource with a query string similar to `?host=HOSTNAME` to retrieve a JSON object containing host variables for the specified host. The response data is equivalent to that returned by passing diff --git a/awx/main/tests/inventory.py b/awx/main/tests/inventory.py index 495e44bd0b..c7285239ab 100644 --- a/awx/main/tests/inventory.py +++ b/awx/main/tests/inventory.py @@ -663,7 +663,60 @@ class InventoryTest(BaseTest): # on a group resource, I can see related resources for variables, inventories, and children # and these work - def test_get_inventory_tree(self): + def test_get_inventory_script_view(self): + i_a = self.inventory_a + i_a.variables = json.dumps({'i-vars': 123}) + i_a.save() + # Group A is parent of B, B is parent of C, C is parent of D. + g_a = i_a.groups.create(name='A', variables=json.dumps({'A-vars': 'AAA'})) + g_b = i_a.groups.create(name='B', variables=json.dumps({'B-vars': 'BBB'})) + g_b.parents.add(g_a) + g_c = i_a.groups.create(name='C', variables=json.dumps({'C-vars': 'CCC'})) + g_c.parents.add(g_b) + g_d = i_a.groups.create(name='D', variables=json.dumps({'D-vars': 'DDD'})) + g_d.parents.add(g_c) + # Each group "X" contains one host "x". + h_a = i_a.hosts.create(name='a', variables=json.dumps({'a-vars': 'aaa'})) + h_a.groups.add(g_a) + h_b = i_a.hosts.create(name='b', variables=json.dumps({'b-vars': 'bbb'})) + h_b.groups.add(g_b) + h_c = i_a.hosts.create(name='c', variables=json.dumps({'c-vars': 'ccc'})) + h_c.groups.add(g_c) + h_d = i_a.hosts.create(name='d', variables=json.dumps({'d-vars': 'ddd'})) + h_d.groups.add(g_d) + + # Old, slow 1.2 way. + url = reverse('main:inventory_script_view', args=(i_a.pk,)) + with self.current_user(self.super_django_user): + response = self.get(url, expect=200) + self.assertTrue('all' in response) + self.assertEqual(response['all']['vars'], i_a.variables_dict) + for g in i_a.groups.all(): + self.assertTrue(g.name in response) + self.assertEqual(response[g.name]['vars'], g.variables_dict) + self.assertEqual(set(response[g.name]['children']), + set(g.children.values_list('name', flat=True))) + self.assertEqual(set(response[g.name]['hosts']), + set(g.hosts.values_list('name', flat=True))) + self.assertFalse('_meta' in response) + for h in i_a.hosts.all(): + h_url = '%s?host=%s' % (url, h.name) + with self.current_user(self.super_django_user): + response = self.get(h_url, expect=200) + self.assertEqual(response, h.variables_dict) + + # New 1.3 way. + url = reverse('main:inventory_script_view', args=(i_a.pk,)) + url = '%s?hostvars=1' % url + with self.current_user(self.super_django_user): + response = self.get(url, expect=200) + self.assertTrue('_meta' in response) + self.assertTrue('hostvars' in response['_meta']) + for h in i_a.hosts.all(): + self.assertEqual(response['_meta']['hostvars'][h.name], + h.variables_dict) + + def test_get_inventory_tree_view(self): # Group A is parent of B, B is parent of C, C is parent of D. g_a = self.inventory_a.groups.create(name='A') g_b = self.inventory_a.groups.create(name='B') diff --git a/awx/main/tests/scripts.py b/awx/main/tests/scripts.py index 0d76e77d27..0a1c33ffd9 100644 --- a/awx/main/tests/scripts.py +++ b/awx/main/tests/scripts.py @@ -208,6 +208,55 @@ class InventoryScriptTest(BaseScriptTest): else: self.assertTrue(len(v['children']) == 0) + def test_list_with_hostvars_inline(self): + inventory = self.inventories[1] + self.assertTrue(inventory.active) + rc, stdout, stderr = self.run_inventory_script(list=True, + inventory=inventory.pk, + hostvars=True) + self.assertEqual(rc, 0, stderr) + data = json.loads(stdout) + groups = inventory.groups.filter(active=True) + groupnames = list(groups.values_list('name', flat=True)) + groupnames.extend(['all', '_meta']) + self.assertEqual(set(data.keys()), set(groupnames)) + all_hostnames = set() + # Groups for this inventory should have hosts, variable data, and one + # parent/child relationship. + for k,v in data.items(): + self.assertTrue(isinstance(v, dict)) + if k == 'all': + self.assertEqual(v.get('vars', {}), inventory.variables_dict) + continue + if k == '_meta': + continue + group = inventory.groups.get(active=True, name=k) + hosts = group.hosts.filter(active=True) + hostnames = hosts.values_list('name', flat=True) + all_hostnames.update(hostnames) + self.assertEqual(set(v.get('hosts', [])), set(hostnames)) + if group.variables: + self.assertEqual(v.get('vars', {}), group.variables_dict) + if k == 'group-3': + children = group.children.filter(active=True) + childnames = children.values_list('name', flat=True) + self.assertEqual(set(v.get('children', [])), set(childnames)) + else: + self.assertTrue(len(v['children']) == 0) + # Check hostvars in ['_meta']['hostvars'] dict. + for hostname in all_hostnames: + self.assertTrue(hostname in data['_meta']['hostvars']) + host = inventory.hosts.get(name=hostname) + self.assertEqual(data['_meta']['hostvars'][hostname], + host.variables_dict) + # Hostvars can also be requested via environment variable. + os.environ['INVENTORY_HOSTVARS'] = str(True) + rc, stdout, stderr = self.run_inventory_script(list=True, + inventory=inventory.pk) + self.assertEqual(rc, 0, stderr) + data = json.loads(stdout) + self.assertTrue('_meta' in data) + def test_valid_host(self): # Host without variable data. inventory = self.inventories[0] @@ -280,11 +329,10 @@ class InventoryScriptTest(BaseScriptTest): self.assertNotEqual(rc, 0, stderr) self.assertEqual(json.loads(stdout), {}) - def _test_with_both_list_and_host_arguments(self): + def test_with_both_list_and_host_arguments(self): inventory = self.inventories[0] self.assertTrue(inventory.active) os.environ['INVENTORY_ID'] = str(inventory.pk) rc, stdout, stderr = self.run_inventory_script(list=True, host='blah') self.assertNotEqual(rc, 0, stderr) self.assertEqual(json.loads(stdout), {}) - diff --git a/awx/main/utils.py b/awx/main/utils.py index f489138985..173d336e9b 100644 --- a/awx/main/utils.py +++ b/awx/main/utils.py @@ -1,13 +1,17 @@ +# Copyright (c) 2013 AnsibleWorks, Inc. +# All Rights Reserved. + # Python import logging import re +import subprocess import sys # Django REST Framework from rest_framework.exceptions import ParseError, PermissionDenied __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', - 'get_awx_version'] + 'get_ansible_version', 'get_awx_version'] def get_object_or_400(klass, *args, **kwargs): ''' @@ -53,6 +57,18 @@ class RequireDebugTrueOrTest(logging.Filter): from django.conf import settings return settings.DEBUG or 'test' in sys.argv +def get_ansible_version(): + ''' + Return Ansible version installed. + ''' + try: + proc = subprocess.Popen(['ansible', '--version'], + stdout=subprocess.PIPE) + result = proc.communicate()[0] + return result.lower().replace('ansible', '').strip() + except: + return 'unknown' + def get_awx_version(): ''' Return AWX version as reported by setuptools. diff --git a/awx/main/views.py b/awx/main/views.py index acaa3abd28..3c88d22953 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -111,6 +111,7 @@ class ApiV1ConfigView(APIView): time_zone=settings.TIME_ZONE, license_info=license_data, version=get_awx_version(), + ansible_version=get_ansible_version(), ) if request.user.is_superuser or request.user.admin_of_organizations.filter(active=True).count(): data.update(dict( @@ -584,6 +585,7 @@ class InventoryScriptView(RetrieveAPIView): def retrieve(self, request, *args, **kwargs): self.object = self.get_object() hostname = request.QUERY_PARAMS.get('host', '') + hostvars = bool(request.QUERY_PARAMS.get('hostvars', '')) if hostname: host = get_object_or_404(self.object.hosts, active=True, name=hostname) @@ -603,6 +605,12 @@ class InventoryScriptView(RetrieveAPIView): group_info['vars'] = group.variables_dict data[group.name] = group_info + if hostvars: + data.setdefault('_meta', SortedDict()) + data['_meta'].setdefault('hostvars', SortedDict()) + for host in self.object.hosts.filter(active=True): + data['_meta']['hostvars'][host.name] = host.variables_dict + # workaround for Ansible inventory bug (github #3687), localhost # must be explicitly listed in the all group for dynamic inventory # scripts to pick it up. diff --git a/awx/scripts/inventory.py b/awx/scripts/inventory.py index ae58ba9b85..431daf7f88 100755 --- a/awx/scripts/inventory.py +++ b/awx/scripts/inventory.py @@ -78,6 +78,8 @@ class InventoryScript(object): url_path = '/api/v1/inventories/%d/script/' % self.inventory_id if self.hostname: url_path += '?%s' % urllib.urlencode({'host': self.hostname}) + elif self.hostvars: + url_path += '?%s' % urllib.urlencode({'hostvars': 1}) url = urlparse.urljoin(url, url_path) response = requests.get(url, auth=auth) response.raise_for_status() @@ -107,6 +109,8 @@ class InventoryScript(object): raise ValueError('No inventory ID specified') self.hostname = self.options.get('hostname', '') self.list_ = self.options.get('list', False) + self.hostvars = bool(self.options.get('hostvars', False) or + os.getenv('INVENTORY_HOSTVARS', '')) self.indent = self.options.get('indent', None) if self.list_ and self.hostname: raise RuntimeError('Only --list or --host may be specified') @@ -147,6 +151,10 @@ def main(): 'using INVENTORY_ID environment variable)') parser.add_option('--list', action='store_true', dest='list', default=False, help='Return JSON hash of host groups.') + parser.add_option('--hostvars', action='store_true', dest='hostvars', + default=False, help='Return hostvars inline with --list,' + ' under ["_meta"]["hostvars"]. Can also be specified ' + 'using INVENTORY_HOSTVARS environment variable.') parser.add_option('--host', dest='hostname', default='', help='Return JSON hash of host vars.') parser.add_option('--indent', dest='indent', type='int', default=None,