diff --git a/awx/api/views.py b/awx/api/views.py index ad3185d98d..a498aa7668 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -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))) diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 8c42303edf..b61f4a64c9 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -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 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(): diff --git a/awx/main/migrations/0038_v320_release.py b/awx/main/migrations/0038_v320_release.py index 009e9432ea..1c7fcb2953 100644 --- a/awx/main/migrations/0038_v320_release.py +++ b/awx/main/migrations/0038_v320_release.py @@ -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', diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index c5a0d83dfd..6004ea8854 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -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 ''' diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 37d83f4815..4d18fb9f20 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -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) diff --git a/awx/main/tests/functional/commands/test_inventory_import.py b/awx/main/tests/functional/commands/test_inventory_import.py index 9f0cf97b76..4efa8ca08e 100644 --- a/awx/main/tests/functional/commands/test_inventory_import.py +++ b/awx/main/tests/functional/commands/test_inventory_import.py @@ -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( diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index 8187c9aea6..443951e278 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -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() diff --git a/docs/scm_file_inventory.md b/docs/scm_file_inventory.md index 8aa26cfc1b..4fbfbb373a 100644 --- a/docs/scm_file_inventory.md +++ b/docs/scm_file_inventory.md @@ -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.