refactor inventory Loaders for ansible-inventory

* remove support for loading from executable and static files
* instead use ansible-inventory with fallback to backport
* provide private file dir in task manager for cred injection
* durable management of tmp dirs for user scripts

* new 'scm' source choice for scm-type
* update SCM inventory docs to new reality
This commit is contained in:
AlanCoding 2017-04-26 14:40:36 -04:00
parent a92c7cec7d
commit 2f62182940
8 changed files with 177 additions and 173 deletions

View File

@ -2353,7 +2353,7 @@ class InventorySourceUpdateView(RetrieveAPIView):
def post(self, request, *args, **kwargs):
obj = self.get_object()
if obj.source == 'file' and obj.scm_project_id is not None:
if obj.source == 'scm':
raise PermissionDenied(detail=_(
'Update the project `{}` in order to update this inventory source.'.format(
obj.scm_project.name)))

View File

@ -11,10 +11,12 @@ import subprocess
import sys
import time
import traceback
import shutil
# Django
from django.conf import settings
from django.core.management.base import NoArgsCommand, CommandError
from django.core.exceptions import ImproperlyConfigured
from django.db import connection, transaction
from django.utils.encoding import smart_text
@ -24,7 +26,8 @@ from awx.main.task_engine import TaskEnhancer
from awx.main.utils import (
ignore_inventory_computed_fields,
check_proot_installed,
wrap_args_with_proot
wrap_args_with_proot,
build_proot_temp_dir
)
from awx.main.utils.mem_inventory import MemInventory, dict_to_mem_data
from awx.main.signals import disable_activity_stream
@ -48,52 +51,109 @@ Demo mode free license count exceeded, would bring available instances to %(new_
See http://www.ansible.com/renew for licensing information.'''
# if called with --list, inventory outputs a JSON representing everything
# in the inventory. Supported cases are maintained in tests.
# if called with --host <host_record_name> outputs JSON for that host
def functioning_dir(path):
if os.path.isdir(path):
return path
return os.path.dirname(path)
class BaseLoader(object):
use_proot = True
class AnsibleInventoryLoader(object):
'''
Given executable `source` (directory, executable, or file) this will
use the ansible-inventory CLI utility to convert it into in-memory
representational objects. Example:
/usr/bin/ansible/ansible-inventory -i hosts --list
If it fails to find this, it uses the backported script instead
'''
def __init__(self, source, group_filter_re=None, host_filter_re=None):
def __init__(self, source, group_filter_re=None, host_filter_re=None, is_custom=False):
self.source = source
self.exe_dir = os.path.dirname(source)
self.inventory = MemInventory(
group_filter_re=group_filter_re, host_filter_re=host_filter_re)
self.is_custom = is_custom
self.tmp_private_dir = None
self.method = 'ansible-inventory'
self.group_filter_re = group_filter_re
self.host_filter_re = host_filter_re
def build_env(self):
# Use ansible venv if it's available and setup to use
env = dict(os.environ.items())
if settings.ANSIBLE_USE_VENV:
env['VIRTUAL_ENV'] = settings.ANSIBLE_VENV_PATH
# env['VIRTUAL_ENV'] += settings.ANSIBLE_VENV_PATH
env['PATH'] = os.path.join(settings.ANSIBLE_VENV_PATH, "bin") + ":" + env['PATH']
# env['PATH'] += os.path.join(settings.ANSIBLE_VENV_PATH, "bin") + ":" + env['PATH']
venv_libdir = os.path.join(settings.ANSIBLE_VENV_PATH, "lib")
env.pop('PYTHONPATH', None) # default to none if no python_ver matches
for python_ver in ["python2.7", "python2.6"]:
if os.path.isdir(os.path.join(venv_libdir, python_ver)):
env['PYTHONPATH'] = os.path.join(venv_libdir, python_ver, "site-packages") + ":"
break
env['PYTHONPATH'] += os.path.abspath(os.path.join(settings.BASE_DIR, '..')) + ":"
return env
def get_base_args(self):
# get ansible-inventory absolute path for running in bubblewrap/proot, in Popen
for path in os.environ["PATH"].split(os.pathsep):
potential_path = os.path.join(path.strip('"'), 'ansible-inventory')
if os.path.isfile(potential_path) and os.access(potential_path, os.X_OK):
return [potential_path, '-i', self.source]
# ansible-inventory was not found, look for backported module
abs_module_path = os.path.abspath(os.path.join(
os.path.dirname(__file__), '..', '..', '..', 'plugins',
'ansible_inventory', 'backport.py'))
self.method = 'ansible-inventory backport'
if not os.path.exists(abs_module_path):
raise ImproperlyConfigured('Can not find inventory module')
return [abs_module_path, '-i', self.source]
def get_proot_args(self, cmd, env):
source_dir = functioning_dir(self.source)
cwd = os.getcwd()
if not check_proot_installed():
raise RuntimeError("proot is not installed but is configured for use")
kwargs = {}
if self.is_custom:
# use source's tmp dir for proot, task manager will delete folder
logger.debug("Using provided directory '{}' for isolation.".format(source_dir))
kwargs['proot_temp_dir'] = source_dir
cwd = source_dir
else:
# we can not safely store tmp data in source dir or trust script contents
if env['AWX_PRIVATE_DATA_DIR']:
# If this is non-blank, file credentials are being used and we need access
private_data_dir = functioning_dir(env['AWX_PRIVATE_DATA_DIR'])
logger.debug("Using private credential data in '{}'.".format(private_data_dir))
kwargs['private_data_dir'] = private_data_dir
self.tmp_private_dir = build_proot_temp_dir()
logger.debug("Using fresh temporary directory '{}' for isolation.".format(self.tmp_private_dir))
kwargs['proot_temp_dir'] = self.tmp_private_dir
# Run from source's location so that custom script contents are in `show_paths`
cwd = functioning_dir(self.source)
logger.debug("Running from `{}` working directory.".format(cwd))
return wrap_args_with_proot(cmd, cwd, **kwargs)
def command_to_json(self, cmd):
data = {}
stdout, stderr = '', ''
try:
if self.use_proot and getattr(settings, 'AWX_PROOT_ENABLED', False):
if not check_proot_installed():
raise RuntimeError("proot is not installed but is configured for use")
kwargs = {'proot_temp_dir': self.exe_dir} # TODO: Remove proot dir
cmd = wrap_args_with_proot(cmd, self.exe_dir, **kwargs)
env = self.build_env()
env = self.build_env()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
stdout, stderr = proc.communicate()
if proc.returncode != 0:
raise RuntimeError('%r failed (rc=%d) with output: %s' % (cmd, proc.returncode, stderr))
if ((self.is_custom or 'AWX_PRIVATE_DATA_DIR' in env) and
getattr(settings, 'AWX_PROOT_ENABLED', False)):
cmd = self.get_proot_args(cmd, env)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
stdout, stderr = proc.communicate()
if self.tmp_private_dir:
shutil.rmtree(self.tmp_private_dir, True)
if proc.returncode != 0:
raise RuntimeError('%s failed (rc=%d) with output:\n%s' % (self.method, proc.returncode, stderr))
elif 'file not found' in stderr:
# File not visible to inventory module due proot (exit code 0, Ansible behavior)
raise IOError('Inventory module failed to find source {} with output:\n{}.'.format(self.source, stderr))
try:
data = json.loads(stdout)
if not isinstance(data, dict):
raise TypeError('Returned JSON must be a dictionary, got %s instead' % str(type(data)))
@ -102,105 +162,22 @@ class BaseLoader(object):
raise
return data
def build_base_args(self):
raise NotImplementedError
def load(self):
base_args = self.build_base_args()
logger.info('Reading executable JSON source: %s', ' '.join(base_args))
base_args = self.get_base_args()
logger.info('Reading Ansible inventory source: %s', self.source)
data = self.command_to_json(base_args + ['--list'])
self.has_hostvars = '_meta' in data and 'hostvars' in data['_meta']
inventory = dict_to_mem_data(data, inventory=self.inventory)
logger.info('Processing JSON output...')
inventory = MemInventory(
group_filter_re=self.group_filter_re, host_filter_re=self.host_filter_re)
inventory = dict_to_mem_data(data, inventory=inventory)
return inventory
class AnsibleInventoryLoader(BaseLoader):
'''
Given executable `source` directory, executable, or file, this will
use the ansible-inventory CLI utility to convert it into in-memory
representational objects. Example:
/usr/bin/ansible/ansible-inventory -i hosts --list
'''
def build_base_args(self):
# Need absolute path of anisble-inventory in order to run inside
# of bubblewrap, inside of Popen
for path in os.environ["PATH"].split(os.pathsep):
potential_path = os.path.join(path.strip('"'), 'ansible-inventory')
if os.path.isfile(potential_path) and os.access(potential_path, os.X_OK):
return [potential_path]
raise RuntimeError(
'ImproperlyConfigured: Called with modern method but '
'not detect ansible-inventory on this system. '
'Check to see that system Ansible is 2.4 or higher.')
# TODO: delete after Ansible 2.3 is deprecated
class InventoryPluginLoader(BaseLoader):
'''
Implements a different use pattern for loading JSON content from an
Ansible inventory module, example:
/path/ansible_inventory_module.py -i my_inventory.ini --list
'''
def __init__(self, source, module, *args, **kwargs):
super(InventoryPluginLoader, self).__init__(source, *args, **kwargs)
assert module in ['legacy', 'backport'], (
'Supported modules are `legacy` and `backport`, received {}'.format(module))
self.module = module
# self.use_proot = False
def build_env(self):
if self.module == 'legacy':
# legacy script does not rely on Ansible imports
return dict(os.environ.items())
return super(InventoryPluginLoader, self).build_env()
def build_base_args(self):
abs_module_path = os.path.abspath(os.path.join(
os.path.dirname(__file__), '..', '..', '..', 'plugins',
'ansible_inventory', '{}.py'.format(self.module)))
return [abs_module_path, '-i', self.source]
# TODO: delete after Ansible 2.3 is deprecated
class ExecutableJsonLoader(BaseLoader):
'''
Directly calls an inventory script, example:
/path/ec2.py --list
'''
def __init__(self, source, is_custom=False, **kwargs):
super(ExecutableJsonLoader, self).__init__(source, **kwargs)
self.use_proot = is_custom
def build_base_args(self):
return [self.source]
def load(self):
inventory = super(ExecutableJsonLoader, self).load()
# Invoke the executable once for each host name we've built up
# to set their variables
if not self.has_hostvars:
base_args = self.build_base_args()
for k,v in self.inventory.all_group.all_hosts.iteritems():
host_data = self.command_to_json(
base_args + ['--host', k.encode("utf-8")])
if isinstance(host_data, dict):
v.variables.update(host_data)
else:
logger.warning('Expected dict of vars for '
'host "%s", got %s instead',
k, str(type(host_data)))
return inventory
def load_inventory_source(source, group_filter_re=None,
host_filter_re=None, exclude_empty_groups=False,
is_custom=False, method='legacy'):
is_custom=False):
'''
Load inventory from given source directory or file.
'''
@ -209,35 +186,17 @@ def load_inventory_source(source, group_filter_re=None,
source = source.replace('azure.py', 'windows_azure.py')
source = source.replace('satellite6.py', 'foreman.py')
source = source.replace('vmware.py', 'vmware_inventory.py')
logger.debug('Analyzing type of source: %s', source)
if not os.path.exists(source):
raise IOError('Source does not exist: %s' % source)
source = os.path.join(os.getcwd(), os.path.dirname(source),
os.path.basename(source))
source = os.path.normpath(os.path.abspath(source))
# TODO: delete options for 'legacy' and 'backport' after Ansible 2.3 deprecated
if method == 'modern':
inventory = AnsibleInventoryLoader(
source=source,
group_filter_re=group_filter_re,
host_filter_re=host_filter_re).load()
elif method == 'legacy' and (os.access(source, os.X_OK) and not os.path.isdir(source)):
# Legacy method of loading executable files
inventory = ExecutableJsonLoader(
source=source,
group_filter_re=group_filter_re,
host_filter_re=host_filter_re,
is_custom=is_custom).load()
else:
# Load using specified ansible-inventory module
inventory = InventoryPluginLoader(
source=source,
module=method,
group_filter_re=group_filter_re,
host_filter_re=host_filter_re).load()
inventory = AnsibleInventoryLoader(
source=source,
group_filter_re=group_filter_re,
host_filter_re=host_filter_re,
is_custom=is_custom).load()
logger.debug('Finished loading from source: %s', source)
# Exclude groups that are completely empty.
@ -299,12 +258,6 @@ class Command(NoArgsCommand):
default=None, metavar='v', help='host variable that '
'specifies the unique, immutable instance ID, may be '
'specified as "foo.bar" to traverse nested dicts.'),
# TODO: remove --method option when Ansible 2.3 is deprecated
make_option('--method', dest='method', type='choice',
choices=['modern', 'backport', 'legacy'],
default='legacy', help='Method for importing inventory '
'to use, distinguishing whether to use `ansible-inventory`, '
'its backport, or the legacy algorithms.'),
)
def set_logging_level(self):
@ -975,8 +928,7 @@ class Command(NoArgsCommand):
self.group_filter_re,
self.host_filter_re,
self.exclude_empty_groups,
self.is_custom,
options.get('method'))
self.is_custom)
self.all_group.debug_tree()
with batch_role_ancestor_rebuilding():

View File

@ -76,12 +76,12 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='inventorysource',
name='source',
field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script Locally or in Project'), (b'rax', 'Rackspace Cloud Servers'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure', 'Microsoft Azure Classic (deprecated)'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'custom', 'Custom Script')]),
field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script'), (b'scm', 'Sourced from a project in Tower'), (b'rax', 'Rackspace Cloud Servers'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure', 'Microsoft Azure Classic (deprecated)'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'custom', 'Custom Script')]),
),
migrations.AlterField(
model_name='inventoryupdate',
name='source',
field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script Locally or in Project'), (b'rax', 'Rackspace Cloud Servers'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure', 'Microsoft Azure Classic (deprecated)'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'custom', 'Custom Script')]),
field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script'), (b'scm', 'Sourced from a project in Tower'), (b'rax', 'Rackspace Cloud Servers'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure', 'Microsoft Azure Classic (deprecated)'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'custom', 'Custom Script')]),
),
migrations.AlterField(
model_name='inventorysource',

View File

@ -720,7 +720,8 @@ class InventorySourceOptions(BaseModel):
SOURCE_CHOICES = [
('', _('Manual')),
('file', _('File, Directory or Script Locally or in Project')),
('file', _('File, Directory or Script')),
('scm', _('Sourced from a project in Tower')),
('rax', _('Rackspace Cloud Servers')),
('ec2', _('Amazon EC2')),
('gce', _('Google Compute Engine')),
@ -991,7 +992,7 @@ class InventorySourceOptions(BaseModel):
if not self.source:
return None
cred = self.credential
if cred and self.source != 'custom':
if cred and self.source not in ('custom', 'scm'):
# If a credential was provided, it's important that it matches
# the actual inventory source being used (Amazon requires Amazon
# credentials; Rackspace requires Rackspace credentials; etc...)
@ -1135,7 +1136,7 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions):
# if it hasn't been specified, then we're just doing a normal save.
update_fields = kwargs.get('update_fields', [])
is_new_instance = not bool(self.pk)
is_scm_type = self.scm_project_id is not None
is_scm_type = self.scm_project_id is not None and self.source == 'scm'
# Set name automatically. Include PK (or placeholder) to make sure the names are always unique.
replace_text = '__replace_%s__' % now()
@ -1336,6 +1337,8 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin):
if (self.source not in ('custom', 'ec2') and
not (self.credential)):
return False
elif self.source in ('file', 'scm'):
return False
return True
'''

View File

@ -1689,13 +1689,17 @@ class RunInventoryUpdate(BaseTask):
env['FOREMAN_INI_PATH'] = cloud_credential
elif inventory_update.source == 'cloudforms':
env['CLOUDFORMS_INI_PATH'] = cloud_credential
elif inventory_update.source == 'file':
elif inventory_update.source == 'scm':
# Parse source_vars to dict, update env.
env.update(parse_yaml_or_json(inventory_update.source_vars))
elif inventory_update.source == 'custom':
for env_k in inventory_update.source_vars_dict:
if str(env_k) not in env and str(env_k) not in settings.INV_ENV_VARIABLE_BLACKLIST:
env[str(env_k)] = unicode(inventory_update.source_vars_dict[env_k])
elif inventory_update.source == 'file':
raise NotImplementedError('Can not update file sources through the task system.')
# add private_data_files
env['AWX_PRIVATE_DATA_DIR'] = kwargs.get('private_data_dir', '')
return env
def build_args(self, inventory_update, **kwargs):
@ -1759,18 +1763,8 @@ class RunInventoryUpdate(BaseTask):
getattr(settings, '%s_INSTANCE_ID_VAR' % src.upper()),
])
elif inventory_update.source == 'file':
elif inventory_update.source == 'scm':
args.append(inventory_update.get_actual_source_path())
if hasattr(settings, 'ANSIBLE_INVENTORY_MODULE'):
module_name = settings.ANSIBLE_INVENTORY_MODULE
else:
module_name = 'backport'
v = get_ansible_version()
if Version(v) > Version('2.4'):
module_name = 'modern'
elif Version(v) < Version('2.2'):
module_name = 'legacy'
args.extend(['--method', module_name])
elif inventory_update.source == 'custom':
runpath = tempfile.mkdtemp(prefix='ansible_tower_launch_')
handle, path = tempfile.mkstemp(dir=runpath)

View File

@ -117,7 +117,7 @@ class TestInvalidOptionsFunctional:
@mock.patch.object(inventory_import.Command, 'set_logging_level', mock_logging)
class TestINIImports:
@mock.patch.object(inventory_import.BaseLoader, 'load', mock.MagicMock(return_value=TEST_MEM_OBJECTS))
@mock.patch.object(inventory_import.AnsibleInventoryLoader, 'load', mock.MagicMock(return_value=TEST_MEM_OBJECTS))
def test_inventory_single_ini_import(self, inventory, capsys):
cmd = inventory_import.Command()
r = cmd.handle_noargs(

View File

@ -12,7 +12,8 @@ class TestSCMUpdateFeatures:
inv_src = InventorySource(
scm_project=project,
source_path='inventory_file',
inventory=inventory)
inventory=inventory,
source='scm')
with mock.patch.object(inv_src.scm_project, 'update') as mck_update:
inv_src.save()
mck_update.assert_called_once_with()

View File

@ -11,6 +11,12 @@ Fields that should be specified on creation of SCM inventory source:
- `source_path` - relative path inside of the project indicating a
directory or a file, if left blank, "" is still a relative path
indicating the root directory of the project
- the `source` field should be set to "scm"
Additionally:
- `source_vars` - if these are set on a "file" type inventory source
then they will be passed to the environment vars when running
A user should not be able to update this inventory source via through
the endpoint `/inventory_sources/N/update/`. Instead, they should update
@ -40,18 +46,26 @@ update the project.
> Any Inventory Ansible supports should be supported by this feature
This statement is the overall goal and should hold true absolutely for
Ansible version 2.4 and beyond due to the use of `ansible-inventory`.
Versions of Ansible before that may not support all valid inventory syntax
because the internal mechanism is different.
This is accomplished by making use of the `ansible-inventory` command.
the inventory import tower-manage command will check for the existnce
of `ansible-inventory` and if it is not present, it will call a backported
version of it. The backport is maintained as its own GPL3 licensed
repository.
Documentation should reflect the limitations of inventory file syntax
support in old Ansible versions.
https://github.com/ansible/ansible-inventory-backport
# Acceptance Criteria Notes
Because the internal mechanism is different, we need some coverage
testing with Ansible versions pre-2.4 and after.
# Acceptance Criteria Use Cases
Some test scenarios to look at:
- Obviously use a git repo with examples of host patterns, etc.
- Test projects that use scripts
- Test projects that have multiple inventory files in a directory,
group_vars, host_vars, etc.
- Test scripts in the project repo
- Test scripts that use environment variables provided by a credential
in Tower
- Test multiple inventories that use the same project, pointing to different
files / directories inside of the project
- Feature works correctly even if project doesn't have any playbook files
@ -61,3 +75,43 @@ Some test scenarios to look at:
- If the project SCM update encounters errors, it should not run the
inventory updates
# Notes for Official Documentation
The API guide should summarize what is in the use details.
Once the UI implementation is done, the product docs should cover its
standard use.
## Update-on-launch
This type of inventory source will not allow the `update_on_launch` field
to be set to True. This is because of concerns related to the task
manager job dependency tree.
We should document the alternatives for a user to accomplish the same thing
through in a different way.
### Alternative 1: Use same project for playbook
You can make a job template that uses a project as well as an inventory
that updates from that same project. In this case, you can set the project
to `update_on_launch`, in which case it will trigger an inventory update
if needed.
### Alternative 2: Use the project in a workflow
If you must use a different project for the playbook than for the inventory
source, then you can still place the project in a workflow and then have
a job template run on success of the project update.
This is guaranteed to have the inventory update "on time" (by this we mean
that the inventory changes are complete before the job template is launched),
because the project does not transition to the completed state
until the inventory update is finished.
Note that a failed inventory update does not mark the project as failed.
## Lazy inventory updates
It should also be noted that not every project update will trigger a
corresponding inventory update. If the project revision has not changed
and the inventory has not been edited, the inventory update will not fire.