From f59ced57bc9c4d15b40f6ed77105a0f67579b4bc Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 18 Nov 2022 10:48:03 -0500 Subject: [PATCH 01/33] Model and task changes for constructed inventory Add in required setting about empty groups --- awx/api/serializers.py | 14 ++-- awx/api/urls/inventory.py | 2 + awx/api/views/inventory.py | 7 ++ .../migrations/0175_constructed_inventory.py | 82 +++++++++++++++++++ awx/main/models/inventory.py | 32 ++++++++ awx/main/tasks/jobs.py | 28 +++++-- .../tests/functional/models/test_inventory.py | 3 +- awx/settings/defaults.py | 5 ++ 8 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 awx/main/migrations/0175_constructed_inventory.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 1ff00854c2..cafd51a835 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1672,13 +1672,8 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): res.update( dict( hosts=self.reverse('api:inventory_hosts_list', kwargs={'pk': obj.pk}), - groups=self.reverse('api:inventory_groups_list', kwargs={'pk': obj.pk}), - root_groups=self.reverse('api:inventory_root_groups_list', kwargs={'pk': obj.pk}), variable_data=self.reverse('api:inventory_variable_data', kwargs={'pk': obj.pk}), script=self.reverse('api:inventory_script_view', kwargs={'pk': obj.pk}), - tree=self.reverse('api:inventory_tree_view', kwargs={'pk': obj.pk}), - inventory_sources=self.reverse('api:inventory_inventory_sources_list', kwargs={'pk': obj.pk}), - update_inventory_sources=self.reverse('api:inventory_inventory_sources_update', kwargs={'pk': obj.pk}), activity_stream=self.reverse('api:inventory_activity_stream_list', kwargs={'pk': obj.pk}), job_templates=self.reverse('api:inventory_job_template_list', kwargs={'pk': obj.pk}), ad_hoc_commands=self.reverse('api:inventory_ad_hoc_commands_list', kwargs={'pk': obj.pk}), @@ -1689,8 +1684,17 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): labels=self.reverse('api:inventory_label_list', kwargs={'pk': obj.pk}), ) ) + if obj.kind in ('', 'constructed'): + # links not relevant for the "old" smart inventory + res['groups'] = self.reverse('api:inventory_groups_list', kwargs={'pk': obj.pk}) + res['root_groups'] = self.reverse('api:inventory_root_groups_list', kwargs={'pk': obj.pk}) + res['update_inventory_sources'] = self.reverse('api:inventory_inventory_sources_update', kwargs={'pk': obj.pk}) + res['inventory_sources'] = self.reverse('api:inventory_inventory_sources_list', kwargs={'pk': obj.pk}) + res['tree'] = self.reverse('api:inventory_tree_view', kwargs={'pk': obj.pk}) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) + if obj.kind == 'constructed': + res['source_inventories'] = self.reverse('api:inventory_source_inventories', kwargs={'pk': obj.pk}) return res def to_representation(self, obj): diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index 7e2fa4ebe2..c7d3592c93 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -7,6 +7,7 @@ from awx.api.views.inventory import ( InventoryList, InventoryDetail, InventoryActivityStreamList, + InventorySourceInventoriesList, InventoryJobTemplateList, InventoryAccessList, InventoryObjectRolesList, @@ -37,6 +38,7 @@ urls = [ re_path(r'^(?P[0-9]+)/script/$', InventoryScriptView.as_view(), name='inventory_script_view'), re_path(r'^(?P[0-9]+)/tree/$', InventoryTreeView.as_view(), name='inventory_tree_view'), re_path(r'^(?P[0-9]+)/inventory_sources/$', InventoryInventorySourcesList.as_view(), name='inventory_inventory_sources_list'), + re_path(r'^(?P[0-9]+)/source_inventories/$', InventorySourceInventoriesList.as_view(), name='inventory_source_inventories'), re_path(r'^(?P[0-9]+)/update_inventory_sources/$', InventoryInventorySourcesUpdate.as_view(), name='inventory_inventory_sources_update'), re_path(r'^(?P[0-9]+)/activity_stream/$', InventoryActivityStreamList.as_view(), name='inventory_activity_stream_list'), re_path(r'^(?P[0-9]+)/job_templates/$', InventoryJobTemplateList.as_view(), name='inventory_job_template_list'), diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 72b04d7d8b..032069ea1f 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -94,6 +94,13 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie return Response(dict(error=_("{0}".format(e))), status=status.HTTP_400_BAD_REQUEST) +class InventorySourceInventoriesList(SubListAttachDetachAPIView): + model = Inventory + serializer_class = InventorySerializer + parent_model = Inventory + relationship = 'source_inventories' + + class InventoryActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer diff --git a/awx/main/migrations/0175_constructed_inventory.py b/awx/main/migrations/0175_constructed_inventory.py new file mode 100644 index 0000000000..1c43afc9f4 --- /dev/null +++ b/awx/main/migrations/0175_constructed_inventory.py @@ -0,0 +1,82 @@ +# Generated by Django 3.2.16 on 2022-12-07 14:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0174_ensure_org_ee_admin_roles'), + ] + + operations = [ + migrations.AddField( + model_name='inventory', + name='source_inventories', + field=models.ManyToManyField( + blank=True, + help_text='Only valid for constructed inventories, this links to the inventories that will be used.', + related_name='destination_inventories', + to='main.Inventory', + ), + ), + migrations.AlterField( + model_name='inventory', + name='kind', + field=models.CharField( + blank=True, + choices=[ + ('', 'Hosts have a direct link to this inventory.'), + ('smart', 'Hosts for inventory generated using the host_filter property.'), + ('constructed', 'Parse list of source inventories with the constructed inventory plugin.'), + ], + default='', + help_text='Kind of inventory being represented.', + max_length=32, + ), + ), + migrations.AlterField( + model_name='inventorysource', + name='source', + field=models.CharField( + choices=[ + ('file', 'File, Directory or Script'), + ('constructed', 'Template additional groups and hostvars at runtime'), + ('scm', 'Sourced from a Project'), + ('ec2', 'Amazon EC2'), + ('gce', 'Google Compute Engine'), + ('azure_rm', 'Microsoft Azure Resource Manager'), + ('vmware', 'VMware vCenter'), + ('satellite6', 'Red Hat Satellite 6'), + ('openstack', 'OpenStack'), + ('rhv', 'Red Hat Virtualization'), + ('controller', 'Red Hat Ansible Automation Platform'), + ('insights', 'Red Hat Insights'), + ], + default=None, + max_length=32, + ), + ), + migrations.AlterField( + model_name='inventoryupdate', + name='source', + field=models.CharField( + choices=[ + ('file', 'File, Directory or Script'), + ('constructed', 'Template additional groups and hostvars at runtime'), + ('scm', 'Sourced from a Project'), + ('ec2', 'Amazon EC2'), + ('gce', 'Google Compute Engine'), + ('azure_rm', 'Microsoft Azure Resource Manager'), + ('vmware', 'VMware vCenter'), + ('satellite6', 'Red Hat Satellite 6'), + ('openstack', 'OpenStack'), + ('rhv', 'Red Hat Virtualization'), + ('controller', 'Red Hat Ansible Automation Platform'), + ('insights', 'Red Hat Insights'), + ], + default=None, + max_length=32, + ), + ), + ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 829017ee1d..3f33420686 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -67,6 +67,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): KIND_CHOICES = [ ('', _('Hosts have a direct link to this inventory.')), ('smart', _('Hosts for inventory generated using the host_filter property.')), + ('constructed', _('Parse list of source inventories with the constructed inventory plugin.')), ] class Meta: @@ -139,6 +140,12 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): default=None, help_text=_('Filter that will be applied to the hosts of this inventory.'), ) + source_inventories = models.ManyToManyField( + 'Inventory', + blank=True, + related_name='destination_inventories', + help_text=_('Only valid for constructed inventories, this links to the inventories that will be used.'), + ) instance_groups = OrderedManyToManyField( 'InstanceGroup', blank=True, @@ -431,12 +438,22 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): connection.on_commit(on_commit) + def _enforce_constructed_source(self): + """ + Constructed inventory should always have exactly 1 inventory source, constructed type + this enforces that requirement + """ + if self.kind == 'constructed': + if not self.inventory_sources.exists(): + self.inventory_sources.create(source='constructed', name=f'Auto-created source for: {self.name}'[:512], overwrite=True) + def save(self, *args, **kwargs): self._update_host_smart_inventory_memeberships() super(Inventory, self).save(*args, **kwargs) if self.kind == 'smart' and 'host_filter' in kwargs.get('update_fields', ['host_filter']) and connection.vendor != 'sqlite': # Minimal update of host_count for smart inventory host filter changes self.update_computed_fields() + self._enforce_constructed_source() def delete(self, *args, **kwargs): self._update_host_smart_inventory_memeberships() @@ -872,6 +889,7 @@ class InventorySourceOptions(BaseModel): SOURCE_CHOICES = [ ('file', _('File, Directory or Script')), + ('constructed', _('Template additional groups and hostvars at runtime')), ('scm', _('Sourced from a Project')), ('ec2', _('Amazon EC2')), ('gce', _('Google Compute Engine')), @@ -1407,6 +1425,8 @@ class PluginFileInjector(object): env.update(injector_env) # Preserves current behavior for Ansible change in default planned for 2.10 env['ANSIBLE_TRANSFORM_INVALID_GROUP_CHARS'] = 'never' + # All CLOUD_PROVIDERS sources implement as inventory plugin from collection + env['ANSIBLE_INVENTORY_ENABLED'] = 'auto' return env def _get_shared_env(self, inventory_update, private_data_dir, private_data_files): @@ -1590,5 +1610,17 @@ class insights(PluginFileInjector): use_fqcn = True +class constructed(PluginFileInjector): + plugin_name = 'constructed' + namespace = 'ansible' + collection = 'builtin' + + def build_env(self, *args, **kwargs): + env = super().build_env(*args, **kwargs) + # Enable all types of inventory plugins so we pick up the script files from source inventories + del env['ANSIBLE_INVENTORY_ENABLED'] + return env + + for cls in PluginFileInjector.__subclasses__(): InventorySourceOptions.injectors[cls.__name__] = cls diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 9da4bc074b..f11d676989 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -315,17 +315,22 @@ class BaseTask(object): return env + def write_inventory_file(self, inventory, private_data_dir, file_name, script_params): + script_data = inventory.get_script_data(**script_params) + for hostname, hv in script_data.get('_meta', {}).get('hostvars', {}).items(): + # maintain a list of host_name --> host_id + # so we can associate emitted events to Host objects + self.runner_callback.host_map[hostname] = hv.pop('remote_tower_id', '') + file_content = '#! /usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint(%r)\n' % json.dumps(script_data) + return self.write_private_data_file(private_data_dir, file_name, file_content, sub_dir='inventory', file_permissions=0o700) + def build_inventory(self, instance, private_data_dir): script_params = dict(hostvars=True, towervars=True) if hasattr(instance, 'job_slice_number'): script_params['slice_number'] = instance.job_slice_number script_params['slice_count'] = instance.job_slice_count - script_data = instance.inventory.get_script_data(**script_params) - # maintain a list of host_name --> host_id - # so we can associate emitted events to Host objects - self.runner_callback.host_map = {hostname: hv.pop('remote_tower_id', '') for hostname, hv in script_data.get('_meta', {}).get('hostvars', {}).items()} - file_content = '#! /usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint(%r)\n' % json.dumps(script_data) - return self.write_private_data_file(private_data_dir, 'hosts', file_content, sub_dir='inventory', file_permissions=0o700) + + return self.write_inventory_file(instance.inventory, private_data_dir, 'hosts', script_params) def build_args(self, instance, private_data_dir, passwords): raise NotImplementedError @@ -1469,8 +1474,6 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): if injector is not None: env = injector.build_env(inventory_update, env, private_data_dir, private_data_files) - # All CLOUD_PROVIDERS sources implement as inventory plugin from collection - env['ANSIBLE_INVENTORY_ENABLED'] = 'auto' if inventory_update.source == 'scm': for env_k in inventory_update.source_vars_dict: @@ -1523,6 +1526,15 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): args = ['ansible-inventory', '--list', '--export'] + # special case for constructed inventories, we pass source inventories from database + # these must come in order, and in order _before_ the constructed inventory itself + if inventory_update.inventory.kind == 'constructed': + for source_inventory in inventory_update.inventory.source_inventories.all(): + args.append('-i') + script_params = dict(hostvars=True, towervars=True) + source_inv_path = self.write_inventory_file(source_inventory, private_data_dir, f'hosts_{source_inventory.id}', script_params) + args.append(to_container_path(source_inv_path, private_data_dir)) + # Add arguments for the source inventory file/script/thing rel_path = self.pseudo_build_inventory(inventory_update, private_data_dir) container_location = os.path.join(CONTAINER_ROOT, rel_path) diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index a1db473d3e..70b4425a06 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -169,7 +169,8 @@ class TestInventorySourceInjectors: CLOUD_PROVIDERS constant contains the same names as what are defined within the injectors """ - assert set(CLOUD_PROVIDERS) == set(InventorySource.injectors.keys()) + # slight exception case for constructed, because it has a FQCN but is not a cloud source + assert set(CLOUD_PROVIDERS) | set(['constructed']) == set(InventorySource.injectors.keys()) @pytest.mark.parametrize('source,filename', [('ec2', 'aws_ec2.yml'), ('openstack', 'openstack.yml'), ('gce', 'gcp_compute.yml')]) def test_plugin_filenames(self, source, filename): diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index f3b6c18eef..e8d1963b40 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -760,6 +760,11 @@ CUSTOM_EXCLUDE_EMPTY_GROUPS = False SCM_EXCLUDE_EMPTY_GROUPS = False # SCM_INSTANCE_ID_VAR = +# ---------------- +# -- Constructed -- +# ---------------- +CONSTRUCTED_EXCLUDE_EMPTY_GROUPS = False + # --------------------- # -- Activity Stream -- # --------------------- From e3d39a2728936188491de88479f9665a98c60191 Mon Sep 17 00:00:00 2001 From: Gabe Muniz Date: Thu, 19 Jan 2023 12:40:14 -0500 Subject: [PATCH 02/33] push limit to inventory sources move limit field from InventorySourceSerializer to InventorySourceOptionsSerializer (#13464) InventorySourceOptionsSerializer is the parent for both InventorySourceSerializer and InventoryUpdateSerializer The limit option need to be exposed to both inventory_source and inventory_update Co-Authored-By: Hao Liu <44379968+TheRealHaoLiu@users.noreply.github.com> --- awx/api/serializers.py | 1 + awx/main/migrations/0175_constructed_inventory.py | 10 ++++++++++ awx/main/models/inventory.py | 7 ++++++- awx/main/tasks/jobs.py | 5 +++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index cafd51a835..f927347f63 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2144,6 +2144,7 @@ class InventorySourceOptionsSerializer(BaseSerializer): 'custom_virtualenv', 'timeout', 'verbosity', + 'limit', ) read_only_fields = ('*', 'custom_virtualenv') diff --git a/awx/main/migrations/0175_constructed_inventory.py b/awx/main/migrations/0175_constructed_inventory.py index 1c43afc9f4..4e3309c5df 100644 --- a/awx/main/migrations/0175_constructed_inventory.py +++ b/awx/main/migrations/0175_constructed_inventory.py @@ -79,4 +79,14 @@ class Migration(migrations.Migration): max_length=32, ), ), + migrations.AddField( + model_name='inventorysource', + name='limit', + field=models.TextField(blank=True, default='', help_text='Enter host, group or pattern match'), + ), + migrations.AddField( + model_name='inventoryupdate', + name='limit', + field=models.TextField(blank=True, default='', help_text='Enter host, group or pattern match'), + ), ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 3f33420686..445f76a5cf 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -969,7 +969,7 @@ class InventorySourceOptions(BaseModel): host_filter = models.TextField( blank=True, default='', - help_text=_('Regex where only matching hosts will be imported.'), + help_text=_('This field is deprecated and will be removed in a future release. Regex where only matching hosts will be imported.'), ) overwrite = models.BooleanField( default=False, @@ -989,6 +989,11 @@ class InventorySourceOptions(BaseModel): blank=True, default=1, ) + limit = models.TextField( + blank=True, + default='', + help_text=_("Enter host, group or pattern match"), + ) @staticmethod def cloud_credential_validation(source, cred): diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index f11d676989..8a68a000d4 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -1542,6 +1542,11 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): args.append('-i') args.append(container_location) + # Added this in order to allow older versions of ansible-inventory https://github.com/ansible/ansible/pull/79596 + # limit should be usable in ansible-inventory 2.15+ + if inventory_update.limit: + args.append('--limit') + args.append(inventory_update.limit) args.append('--output') args.append(os.path.join(CONTAINER_ROOT, 'artifacts', str(inventory_update.id), 'output.json')) From aad260bb412978d5bfcec588caaeae63aa1325c6 Mon Sep 17 00:00:00 2001 From: Gabe Muniz Date: Thu, 19 Jan 2023 13:01:45 -0500 Subject: [PATCH 03/33] edit new migration for deprecation of host_filter --- ...ntory.py => 0182_constructed_inventory.py} | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) rename awx/main/migrations/{0175_constructed_inventory.py => 0182_constructed_inventory.py} (82%) diff --git a/awx/main/migrations/0175_constructed_inventory.py b/awx/main/migrations/0182_constructed_inventory.py similarity index 82% rename from awx/main/migrations/0175_constructed_inventory.py rename to awx/main/migrations/0182_constructed_inventory.py index 4e3309c5df..3c37fc66e4 100644 --- a/awx/main/migrations/0175_constructed_inventory.py +++ b/awx/main/migrations/0182_constructed_inventory.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0174_ensure_org_ee_admin_roles'), + ('main', '0181_hostmetricsummarymonthly'), ] operations = [ @@ -89,4 +89,22 @@ class Migration(migrations.Migration): name='limit', field=models.TextField(blank=True, default='', help_text='Enter host, group or pattern match'), ), + migrations.AlterField( + model_name='inventorysource', + name='host_filter', + field=models.TextField( + blank=True, + default='', + help_text='This field is deprecated and will be removed in a future release. Regex where only matching hosts will be imported.', + ), + ), + migrations.AlterField( + model_name='inventoryupdate', + name='host_filter', + field=models.TextField( + blank=True, + default='', + help_text='This field is deprecated and will be removed in a future release. Regex where only matching hosts will be imported.', + ), + ), ] From 57e005b7753de1c5c66a1c71ce69dfaa868b78ac Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 20 Jan 2023 09:21:59 -0500 Subject: [PATCH 04/33] Start on new constructed inventory API view Make the GET function work at most basic level Basic functionality of updating working Add functional test for the GET and PATCH views Add constructed inventory list view for direct creation Add limit field to constructed inventory serializer --- awx/api/serializers.py | 76 +++++++++++++++++++ awx/api/urls/inventory.py | 10 ++- awx/api/urls/urls.py | 3 +- awx/api/views/inventory.py | 17 ++++- awx/api/views/root.py | 1 + .../migrations/0182_constructed_inventory.py | 1 - awx/main/tasks/jobs.py | 2 +- .../tests/functional/api/test_inventory.py | 42 ++++++++++ awx/settings/defaults.py | 2 + 9 files changed, 149 insertions(+), 5 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f927347f63..3406dca77a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1695,6 +1695,7 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) if obj.kind == 'constructed': res['source_inventories'] = self.reverse('api:inventory_source_inventories', kwargs={'pk': obj.pk}) + res['url'] = self.reverse('api:constructed_inventory_detail', kwargs={'pk': obj.pk}) return res def to_representation(self, obj): @@ -1736,6 +1737,81 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): return super(InventorySerializer, self).validate(attrs) +class ConstructedFieldMixin(serializers.Field): + def get_attribute(self, instance): + if not hasattr(instance, '_constructed_inv_src'): + instance._constructed_inv_src = instance.inventory_sources.first() + inv_src = instance._constructed_inv_src + return super().get_attribute(inv_src) # yoink + + +class ConstructedCharField(ConstructedFieldMixin, serializers.CharField): + pass + + +class ConstructedIntegerField(ConstructedFieldMixin, serializers.IntegerField): + pass + + +class ConstructedInventorySerializer(InventorySerializer): + source_vars = ConstructedCharField( + required=False, + default=None, + allow_blank=True, + help_text=_('The source_vars for the related auto-created inventory source, special to constructed inventory.'), + ) + update_cache_timeout = ConstructedIntegerField( + required=False, + allow_null=True, + min_value=0, + default=None, + help_text=_('The cache timeout for the related auto-created inventory source, special to constructed inventory'), + ) + limit = ConstructedCharField( + required=False, + default=None, + allow_blank=True, + help_text=_('The limit to restrict the returned hosts for the related auto-created inventory source, special to constructed inventory.'), + ) + + class Meta: + model = Inventory + fields = ('*', '-host_filter', 'source_vars', 'update_cache_timeout', 'limit') + + def pop_inv_src_data(self, data): + inv_src_data = {} + for field in ('source_vars', 'update_cache_timeout', 'limit'): + if field in data: + # values always need to be removed, as they are not valid for Inventory model + value = data.pop(field) + # null is not valid for any of those fields, taken as not-provided + if value is not None: + inv_src_data[field] = value + return inv_src_data + + def apply_inv_src_data(self, inventory, inv_src_data): + if inv_src_data: + update_fields = [] + inv_src = inventory.inventory_sources.first() + for field, value in inv_src_data.items(): + setattr(inv_src, field, value) + update_fields.append(field) + if update_fields: + inv_src.save(update_fields=update_fields) + + def create(self, validated_data): + inv_src_data = self.pop_inv_src_data(validated_data) + inventory = super().create(validated_data) + self.apply_inv_src_data(inventory, inv_src_data) + return inventory + + def update(self, obj, validated_data): + inv_src_data = self.pop_inv_src_data(validated_data) + obj = super().update(obj, validated_data) + self.apply_inv_src_data(obj, inv_src_data) + return obj + + class InventoryScriptSerializer(InventorySerializer): class Meta: fields = () diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index c7d3592c93..3be18d5249 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -6,6 +6,8 @@ from django.urls import re_path from awx.api.views.inventory import ( InventoryList, InventoryDetail, + ConstructedInventoryDetail, + ConstructedInventoryList, InventoryActivityStreamList, InventorySourceInventoriesList, InventoryJobTemplateList, @@ -50,4 +52,10 @@ urls = [ re_path(r'^(?P[0-9]+)/copy/$', InventoryCopy.as_view(), name='inventory_copy'), ] -__all__ = ['urls'] +# Constructed inventory special views +constructed_inventory_urls = [ + re_path(r'^$', ConstructedInventoryList.as_view(), name='constructed_inventory_list'), + re_path(r'^(?P[0-9]+)/$', ConstructedInventoryDetail.as_view(), name='constructed_inventory_detail'), +] + +__all__ = ['urls', 'constructed_inventory_urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 9eafb51d64..bb27710dcc 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -47,7 +47,7 @@ from .organization import urls as organization_urls from .user import urls as user_urls from .project import urls as project_urls from .project_update import urls as project_update_urls -from .inventory import urls as inventory_urls +from .inventory import urls as inventory_urls, constructed_inventory_urls from .execution_environments import urls as execution_environment_urls from .team import urls as team_urls from .host import urls as host_urls @@ -119,6 +119,7 @@ v2_urls = [ re_path(r'^project_updates/', include(project_update_urls)), re_path(r'^teams/', include(team_urls)), re_path(r'^inventories/', include(inventory_urls)), + re_path(r'^constructed_inventories/', include(constructed_inventory_urls)), re_path(r'^hosts/', include(host_urls)), re_path(r'^host_metrics/', include(host_metric_urls)), # It will be enabled in future version of the AWX diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 032069ea1f..f18bc29fe2 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -31,6 +31,7 @@ from awx.api.views.labels import LabelSubListCreateAttachDetachView from awx.api.serializers import ( InventorySerializer, + ConstructedInventorySerializer, ActivityStreamSerializer, RoleSerializer, InstanceGroupSerializer, @@ -79,7 +80,9 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie # Do not allow changes to an Inventory kind. if kind is not None and obj.kind != kind: - return Response(dict(error=_('You cannot turn a regular inventory into a "smart" inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED) + return Response( + dict(error=_('You cannot turn a regular inventory into a "smart" or "constructed" inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED + ) return super(InventoryDetail, self).update(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): @@ -94,6 +97,18 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie return Response(dict(error=_("{0}".format(e))), status=status.HTTP_400_BAD_REQUEST) +class ConstructedInventoryDetail(InventoryDetail): + serializer_class = ConstructedInventorySerializer + + +class ConstructedInventoryList(InventoryList): + serializer_class = ConstructedInventorySerializer + + def get_queryset(self): + r = super().get_queryset() + return r.filter(kind='constructed') + + class InventorySourceInventoriesList(SubListAttachDetachAPIView): model = Inventory serializer_class = InventorySerializer diff --git a/awx/api/views/root.py b/awx/api/views/root.py index be4d9cc44b..7f33fac4af 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -98,6 +98,7 @@ class ApiVersionRootView(APIView): data['tokens'] = reverse('api:o_auth2_token_list', request=request) data['metrics'] = reverse('api:metrics_view', request=request) data['inventory'] = reverse('api:inventory_list', request=request) + data['constructed_inventory'] = reverse('api:constructed_inventory_list', request=request) data['inventory_sources'] = reverse('api:inventory_source_list', request=request) data['inventory_updates'] = reverse('api:inventory_update_list', request=request) data['groups'] = reverse('api:group_list', request=request) diff --git a/awx/main/migrations/0182_constructed_inventory.py b/awx/main/migrations/0182_constructed_inventory.py index 3c37fc66e4..ff268d376f 100644 --- a/awx/main/migrations/0182_constructed_inventory.py +++ b/awx/main/migrations/0182_constructed_inventory.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ('main', '0181_hostmetricsummarymonthly'), ] diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 8a68a000d4..5b874e14d0 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -320,7 +320,7 @@ class BaseTask(object): for hostname, hv in script_data.get('_meta', {}).get('hostvars', {}).items(): # maintain a list of host_name --> host_id # so we can associate emitted events to Host objects - self.runner_callback.host_map[hostname] = hv.pop('remote_tower_id', '') + self.runner_callback.host_map[hostname] = hv.get('remote_tower_id', '') file_content = '#! /usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint(%r)\n' % json.dumps(script_data) return self.write_private_data_file(private_data_dir, file_name, file_content, sub_dir='inventory', file_permissions=0o700) diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index 80357a22f9..c8139a340a 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -594,3 +594,45 @@ class TestControlledBySCM: rando, expect=403, ) + + +@pytest.mark.django_db +class TestConstructedInventory: + @pytest.fixture + def constructed_inventory(self, organization): + return Inventory.objects.create(name='constructed-test-inventory', kind='constructed', organization=organization) + + def test_get_constructed_inventory(self, constructed_inventory, admin_user, get): + inv_src = constructed_inventory.inventory_sources.first() + inv_src.update_cache_timeout = 53 + inv_src.save(update_fields=['update_cache_timeout']) + r = get(url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), user=admin_user, expect=200) + assert r.data['update_cache_timeout'] == 53 + + def test_patch_constructed_inventory(self, constructed_inventory, admin_user, patch): + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 0 + assert inv_src.limit == '' + r = patch( + url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), + data=dict(update_cache_timeout=54, limit='foobar'), + user=admin_user, + expect=200, + ) + assert r.data['update_cache_timeout'] == 54 + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 54 + assert inv_src.limit == 'foobar' + + def test_create_constructed_inventory(self, constructed_inventory, admin_user, post, organization): + r = post( + url=reverse('api:constructed_inventory_list'), + data=dict(name='constructed-inventory-just-created', kind='constructed', organization=organization.id, update_cache_timeout=55, limit='foobar'), + user=admin_user, + expect=201, + ) + pk = r.data['id'] + constructed_inventory = Inventory.objects.get(pk=pk) + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 55 + assert inv_src.limit == 'foobar' diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index e8d1963b40..e7720e150b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -763,6 +763,8 @@ SCM_EXCLUDE_EMPTY_GROUPS = False # ---------------- # -- Constructed -- # ---------------- +CONSTRUCTED_INSTANCE_ID_VAR = 'remote_tower_id' + CONSTRUCTED_EXCLUDE_EMPTY_GROUPS = False # --------------------- From 510f54b90491337f4bb80cd00825d4ecd46a59fb Mon Sep 17 00:00:00 2001 From: Gabe Muniz Date: Mon, 23 Jan 2023 13:28:03 -0500 Subject: [PATCH 05/33] adding limit to inventory_source collection module --- awx_collection/plugins/modules/inventory_source.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awx_collection/plugins/modules/inventory_source.py b/awx_collection/plugins/modules/inventory_source.py index 1e6939df3f..04ee423a3b 100644 --- a/awx_collection/plugins/modules/inventory_source.py +++ b/awx_collection/plugins/modules/inventory_source.py @@ -64,6 +64,10 @@ options: description: - If specified, AWX will only import hosts that match this regular expression. type: str + limit: + description: + - Enter host, group or pattern match + type: str credential: description: - Credential to use for the source. @@ -172,6 +176,7 @@ def main(): enabled_var=dict(), enabled_value=dict(), host_filter=dict(), + limit=dict(), credential=dict(), execution_environment=dict(), custom_virtualenv=dict(), @@ -279,6 +284,7 @@ def main(): 'enabled_value', 'host_filter', 'scm_branch', + 'limit', ) # Layer in all remaining optional information From c2fe06dd95d5bc4a52c47aaf44b3192b5956b1d2 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 27 Jan 2023 13:06:55 -0500 Subject: [PATCH 06/33] [constructed-inventory] Use control plane EE for constructed inventory and hack temporary image (#13474) * Use control plane EE for constructed inventory and hack temporary image * Update page registry to work with new endpoints --- awx/main/models/inventory.py | 12 +++++++++++- awxkit/awxkit/api/pages/inventory.py | 13 +++++++++++-- awxkit/awxkit/api/resources.py | 2 ++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 445f76a5cf..488bf90059 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -49,7 +49,7 @@ from awx.main.models.notifications import ( from awx.main.models.credential.injectors import _openstack_data from awx.main.utils import _inventory_updates from awx.main.utils.safe_yaml import sanitize_jinja -from awx.main.utils.execution_environments import to_container_path +from awx.main.utils.execution_environments import to_container_path, get_control_plane_execution_environment from awx.main.utils.licensing import server_product_name @@ -995,6 +995,16 @@ class InventorySourceOptions(BaseModel): help_text=_("Enter host, group or pattern match"), ) + def resolve_execution_environment(self): + """ + Project updates, themselves, will use the control plane execution environment. + Jobs using the project can use the default_environment, but the project updates + are not flexible enough to allow customizing the image they use. + """ + if self.inventory.kind == 'constructed': + return get_control_plane_execution_environment() + return super().resolve_execution_environment() + @staticmethod def cloud_credential_validation(source, cred): if not source: diff --git a/awxkit/awxkit/api/pages/inventory.py b/awxkit/awxkit/api/pages/inventory.py index eeace96bd6..f75e7f4170 100644 --- a/awxkit/awxkit/api/pages/inventory.py +++ b/awxkit/awxkit/api/pages/inventory.py @@ -125,14 +125,23 @@ class Inventory(HasCopy, HasCreate, HasInstanceGroups, HasVariables, base.Base): return inv_updates -page.register_page([resources.inventory, (resources.inventories, 'post'), (resources.inventory_copy, 'post')], Inventory) +page.register_page( + [ + resources.inventory, + resources.constructed_inventory, + (resources.inventories, 'post'), + (resources.inventory_copy, 'post'), + (resources.constructed_inventories, 'post'), + ], + Inventory, +) class Inventories(page.PageList, Inventory): pass -page.register_page([resources.inventories, resources.related_inventories], Inventories) +page.register_page([resources.inventories, resources.related_inventories, resources.constructed_inventories], Inventories) class Group(HasCreate, HasVariables, base.Base): diff --git a/awxkit/awxkit/api/resources.py b/awxkit/awxkit/api/resources.py index 448a0bb582..813f4104ee 100644 --- a/awxkit/awxkit/api/resources.py +++ b/awxkit/awxkit/api/resources.py @@ -59,7 +59,9 @@ class Resources(object): _instance_related_jobs = r'instances/\d+/jobs/' _instances = 'instances/' _inventories = 'inventories/' + _constructed_inventories = 'constructed_inventories/' _inventory = r'inventories/\d+/' + _constructed_inventory = r'constructed_inventories/\d+/' _inventory_access_list = r'inventories/\d+/access_list/' _inventory_copy = r'inventories/\d+/copy/' _inventory_labels = r'inventories/\d+/labels/' From 3e5467b472dbb8b6678986bfb37d0e741baa55e6 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Mon, 30 Jan 2023 15:12:40 -0500 Subject: [PATCH 07/33] [constructed-inventory] Add constructed inventory docs and do minor field updates (#13487) * Add constructed inventory docs and do minor field updates Add verbosity field to the constructed views automatically set update_on_launch for the auto-created constructed inventory source --- awx/api/serializers.py | 12 ++- awx/main/models/inventory.py | 2 +- .../inventory_refresh.md | 0 docs/inventory/constructed_inventory.md | 86 +++++++++++++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) rename docs/{inventory => deprecated}/inventory_refresh.md (100%) create mode 100644 docs/inventory/constructed_inventory.md diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3406dca77a..561f99e20d 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1773,14 +1773,22 @@ class ConstructedInventorySerializer(InventorySerializer): allow_blank=True, help_text=_('The limit to restrict the returned hosts for the related auto-created inventory source, special to constructed inventory.'), ) + verbosity = ConstructedIntegerField( + required=False, + allow_null=True, + min_value=0, + max_value=2, + default=None, + help_text=_('The verbosity level for the related auto-created inventory source, special to constructed inventory'), + ) class Meta: model = Inventory - fields = ('*', '-host_filter', 'source_vars', 'update_cache_timeout', 'limit') + fields = ('*', '-host_filter', 'source_vars', 'update_cache_timeout', 'limit', 'verbosity') def pop_inv_src_data(self, data): inv_src_data = {} - for field in ('source_vars', 'update_cache_timeout', 'limit'): + for field in ('source_vars', 'update_cache_timeout', 'limit', 'verbosity'): if field in data: # values always need to be removed, as they are not valid for Inventory model value = data.pop(field) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 488bf90059..461c467081 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -445,7 +445,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): """ if self.kind == 'constructed': if not self.inventory_sources.exists(): - self.inventory_sources.create(source='constructed', name=f'Auto-created source for: {self.name}'[:512], overwrite=True) + self.inventory_sources.create(source='constructed', name=f'Auto-created source for: {self.name}'[:512], overwrite=True, update_on_launch=True) def save(self, *args, **kwargs): self._update_host_smart_inventory_memeberships() diff --git a/docs/inventory/inventory_refresh.md b/docs/deprecated/inventory_refresh.md similarity index 100% rename from docs/inventory/inventory_refresh.md rename to docs/deprecated/inventory_refresh.md diff --git a/docs/inventory/constructed_inventory.md b/docs/inventory/constructed_inventory.md new file mode 100644 index 0000000000..8516723fe3 --- /dev/null +++ b/docs/inventory/constructed_inventory.md @@ -0,0 +1,86 @@ +### Constructed inventory in AWX + +Constructed inventory is a separate "kind" of inventory, along-side of +normal (manual) inventories and "smart" inventories. +The functionality overlaps with smart inventory, and it is intended that +smart inventory is sunsetted and will be eventually removed. + +#### Demo Problem + +This is branched from original demo at: + +https://github.com/AlanCoding/Ansible-inventory-file-examples/tree/master/issues/AWX371 + +Consider that we have 2 original "source" inventories named "East" and "West". + +``` +# East inventory original contents +host1 account_alias=product_dev +host2 account_alias=product_dev state=shutdown +host3 account_alias=sustaining +``` + +``` +# West inventory original contents +host4 account_alias=product_dev +host6 account_alias=product_dev state=shutdown +host5 account_alias=sustaining state=shutdown +``` + +The user's intent is to operate on _shutdown_ hosts in the _product_dev_ group. +So these are two AND conditions that we want to filter on. + +To accomplish this, the user will create a constructed inventory with +the following properties. + +`source_vars` = + +```yaml +plugin: constructed +strict: true +use_vars_plugins: true # https://github.com/ansible/ansible/issues/75365 +groups: + shutdown: resolved_state == "shutdown" + shutdown_in_product_dev: resolved_state == "shutdown" and account_alias == "product_dev" +compose: + resolved_state: state | default("running") +``` + +`limit` = "shutdown_in_product_dev" + +Then when running a job template against the constructed inventory, it should +act on host2 and host6, because those are the two hosts that fit the criteria. + +#### Mechanic + +The constructed inventory contents will be materialized by an inventory update +which runs via `ansible-inventory`. +This is always configured to update-on-launch before a job, +but the user can still select a cache timeout value in case this takes too long. + +When creating a constructed inventory, the API enforces that it always has 1 +inventory source associated with it. +All inventory updates have an associated inventory source, and the fields +needed for constructed inventory (`source_vars` and `limit`) are fields +on the inventory source model normally. + +#### Capabilities + +In addition to filtering on hostvars, users will be able to filter based on +facts, which are prepared before the update in the same way as for jobs. + +For filtering on related objects in the database, users will need to use "meta" +vars that are automatically prepared by the server. +These have names such as: + - `awx_inventory_name` + - `awx_inventory_id` + +#### Best Practices + +It is very important to set the `strict` parameter to `True` so that users +can debug problems with their templates, because these can get complicated. +If the template fails to render, users will get an error in the +associated inventory update for that constructed inventory. + +When encountering errors, it may be prudent to increase `verbosity` to get +more details. From aa06940df5e1b8d9f2242789c539d1af8f6cebc0 Mon Sep 17 00:00:00 2001 From: Gabe Muniz Date: Wed, 1 Feb 2023 14:22:00 -0500 Subject: [PATCH 08/33] force kind to readonly field and set kind to constructed in create --- awx/api/serializers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 561f99e20d..cceb0758bd 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1785,6 +1785,7 @@ class ConstructedInventorySerializer(InventorySerializer): class Meta: model = Inventory fields = ('*', '-host_filter', 'source_vars', 'update_cache_timeout', 'limit', 'verbosity') + read_only_fields = ('*', 'kind') def pop_inv_src_data(self, data): inv_src_data = {} @@ -1808,6 +1809,7 @@ class ConstructedInventorySerializer(InventorySerializer): inv_src.save(update_fields=update_fields) def create(self, validated_data): + validated_data['kind'] = 'constructed' inv_src_data = self.pop_inv_src_data(validated_data) inventory = super().create(validated_data) self.apply_inv_src_data(inventory, inv_src_data) From df6bb5a8b8b9dedeb2723bc05b3c5a7c72a6bf0d Mon Sep 17 00:00:00 2001 From: Gabe Muniz Date: Thu, 2 Feb 2023 21:51:37 -0500 Subject: [PATCH 09/33] Refactor original hosts, add related field Also rename source_inventories to input_inventories --- awx/api/serializers.py | 6 ++++-- awx/api/urls/inventory.py | 4 ++-- awx/api/views/inventory.py | 4 ++-- awx/main/migrations/0182_constructed_inventory.py | 2 +- awx/main/models/inventory.py | 2 +- awx/main/tasks/jobs.py | 4 ++-- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index cceb0758bd..c898bf754f 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1694,8 +1694,8 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) if obj.kind == 'constructed': - res['source_inventories'] = self.reverse('api:inventory_source_inventories', kwargs={'pk': obj.pk}) - res['url'] = self.reverse('api:constructed_inventory_detail', kwargs={'pk': obj.pk}) + res['input_inventories'] = self.reverse('api:inventory_input_inventories', kwargs={'pk': obj.pk}) + res['constructed_url'] = self.reverse('api:constructed_inventory_detail', kwargs={'pk': obj.pk}) return res def to_representation(self, obj): @@ -1875,6 +1875,8 @@ class HostSerializer(BaseSerializerWithVariables): ansible_facts=self.reverse('api:host_ansible_facts_detail', kwargs={'pk': obj.pk}), ) ) + if obj.inventory.kind == 'constructed': + res['original_host'] = self.reverse('api:host_detail', kwargs={'pk': obj.instance_id}) if obj.inventory: res['inventory'] = self.reverse('api:inventory_detail', kwargs={'pk': obj.inventory.pk}) if obj.last_job: diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index 3be18d5249..b83b9b7208 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -9,7 +9,7 @@ from awx.api.views.inventory import ( ConstructedInventoryDetail, ConstructedInventoryList, InventoryActivityStreamList, - InventorySourceInventoriesList, + InventoryInputInventoriesList, InventoryJobTemplateList, InventoryAccessList, InventoryObjectRolesList, @@ -40,7 +40,7 @@ urls = [ re_path(r'^(?P[0-9]+)/script/$', InventoryScriptView.as_view(), name='inventory_script_view'), re_path(r'^(?P[0-9]+)/tree/$', InventoryTreeView.as_view(), name='inventory_tree_view'), re_path(r'^(?P[0-9]+)/inventory_sources/$', InventoryInventorySourcesList.as_view(), name='inventory_inventory_sources_list'), - re_path(r'^(?P[0-9]+)/source_inventories/$', InventorySourceInventoriesList.as_view(), name='inventory_source_inventories'), + re_path(r'^(?P[0-9]+)/input_inventories/$', InventoryInputInventoriesList.as_view(), name='inventory_input_inventories'), re_path(r'^(?P[0-9]+)/update_inventory_sources/$', InventoryInventorySourcesUpdate.as_view(), name='inventory_inventory_sources_update'), re_path(r'^(?P[0-9]+)/activity_stream/$', InventoryActivityStreamList.as_view(), name='inventory_activity_stream_list'), re_path(r'^(?P[0-9]+)/job_templates/$', InventoryJobTemplateList.as_view(), name='inventory_job_template_list'), diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index f18bc29fe2..64550e11c5 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -109,11 +109,11 @@ class ConstructedInventoryList(InventoryList): return r.filter(kind='constructed') -class InventorySourceInventoriesList(SubListAttachDetachAPIView): +class InventoryInputInventoriesList(SubListAttachDetachAPIView): model = Inventory serializer_class = InventorySerializer parent_model = Inventory - relationship = 'source_inventories' + relationship = 'input_inventories' class InventoryActivityStreamList(SubListAPIView): diff --git a/awx/main/migrations/0182_constructed_inventory.py b/awx/main/migrations/0182_constructed_inventory.py index ff268d376f..a41e303597 100644 --- a/awx/main/migrations/0182_constructed_inventory.py +++ b/awx/main/migrations/0182_constructed_inventory.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): operations = [ migrations.AddField( model_name='inventory', - name='source_inventories', + name='input_inventories', field=models.ManyToManyField( blank=True, help_text='Only valid for constructed inventories, this links to the inventories that will be used.', diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 461c467081..75609b91b6 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -140,7 +140,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): default=None, help_text=_('Filter that will be applied to the hosts of this inventory.'), ) - source_inventories = models.ManyToManyField( + input_inventories = models.ManyToManyField( 'Inventory', blank=True, related_name='destination_inventories', diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 5b874e14d0..ef73caacf5 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -1529,10 +1529,10 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): # special case for constructed inventories, we pass source inventories from database # these must come in order, and in order _before_ the constructed inventory itself if inventory_update.inventory.kind == 'constructed': - for source_inventory in inventory_update.inventory.source_inventories.all(): + for input_inventory in inventory_update.inventory.input_inventories.all(): args.append('-i') script_params = dict(hostvars=True, towervars=True) - source_inv_path = self.write_inventory_file(source_inventory, private_data_dir, f'hosts_{source_inventory.id}', script_params) + source_inv_path = self.write_inventory_file(input_inventory, private_data_dir, f'hosts_{input_inventory.id}', script_params) args.append(to_container_path(source_inv_path, private_data_dir)) # Add arguments for the source inventory file/script/thing From e22967d28de40fe40d94fd4818aa9248942f6b4c Mon Sep 17 00:00:00 2001 From: Hao Liu Date: Mon, 6 Feb 2023 14:09:05 -0500 Subject: [PATCH 10/33] add constructed kind to inventory module - add kind 'constructed' to inventory module - add 'input_inventories' field to inventory module Co-authored-by: Rick Elrod Signed-off-by: Rick Elrod --- awx_collection/plugins/modules/inventory.py | 17 +++++++++++++++-- awx_collection/test/awx/test_completeness.py | 4 +++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/awx_collection/plugins/modules/inventory.py b/awx_collection/plugins/modules/inventory.py index 8e739b2211..3a479bcd03 100644 --- a/awx_collection/plugins/modules/inventory.py +++ b/awx_collection/plugins/modules/inventory.py @@ -54,7 +54,7 @@ options: kind: description: - The kind field. Cannot be modified after created. - choices: ["", "smart"] + choices: ["", "smart", "constructed"] type: str host_filter: description: @@ -65,6 +65,11 @@ options: - list of Instance Groups for this Organization to run on. type: list elements: str + input_inventories: + description: + - List of Inventories to use as input for Constructed Inventory. + type: list + elements: str prevent_instance_group_fallback: description: - Prevent falling back to instance groups set on the organization @@ -111,11 +116,12 @@ def main(): description=dict(), organization=dict(required=True), variables=dict(type='dict'), - kind=dict(choices=['', 'smart']), + kind=dict(choices=['', 'smart', 'constructed']), host_filter=dict(), instance_groups=dict(type="list", elements='str'), prevent_instance_group_fallback=dict(type='bool'), state=dict(choices=['present', 'absent'], default='present'), + input_inventories=dict(type='list', elements='str'), ) # Create a module for ourselves @@ -181,6 +187,13 @@ def main(): if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart': module.fail_json(msg='You cannot turn a regular inventory into a "smart" inventory.') + if kind == 'constructed': + input_inventory_names = module.params.get('input_inventories') + if input_inventory_names is not None: + association_fields['input_inventories'] = [] + for item in input_inventory_names: + association_fields['input_inventories'].append(module.resolve_name_to_id('inventories', item)) + # If the state was present and we can let the module build or update the existing inventory, this will return on its own module.create_or_update_if_needed( inventory, diff --git a/awx_collection/test/awx/test_completeness.py b/awx_collection/test/awx/test_completeness.py index ef3d70727a..b4eb0ad3e4 100644 --- a/awx_collection/test/awx/test_completeness.py +++ b/awx_collection/test/awx/test_completeness.py @@ -20,7 +20,9 @@ read_only_endpoints_with_modules = ['settings', 'role', 'project_update', 'workf # If a module should not be created for an endpoint and the endpoint is not read-only add it here # THINK HARD ABOUT DOING THIS -no_module_for_endpoint = [] +no_module_for_endpoint = [ + 'constructed_inventory', # This is a view for inventory with kind=constructed +] # Some modules work on the related fields of an endpoint. These modules will not have an auto-associated endpoint no_endpoint_for_module = [ From 7a7443765180390025c93373bddcbe743867c3a1 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 2 Feb 2023 12:02:08 -0500 Subject: [PATCH 11/33] Add constructed inventory CRUD and subtab routes * Add constructed inventory API model * Add constructed inventory detail view * Add util to switch inventory url based on "kind" --- awx/ui/src/api/index.js | 3 + .../src/api/models/ConstructedInventories.js | 11 + awx/ui/src/api/models/Inventories.js | 7 + .../screens/Inventory/ConstructedInventory.js | 206 +++++++++++++ .../Inventory/ConstructedInventory.test.js | 73 +++++ .../ConstructedInventoryAdd.js | 18 ++ .../ConstructedInventoryAdd.test.js | 15 + .../ConstructedInventoryAdd/index.js | 1 + .../ConstructedInventoryDetail.js | 288 ++++++++++++++++++ .../ConstructedInventoryDetail.test.js | 66 ++++ .../ConstructedInventoryDetail/index.js | 1 + .../ConstructedInventoryEdit.js | 13 + .../ConstructedInventoryEdit.test.js | 15 + .../ConstructedInventoryEdit/index.js | 1 + .../ConstructedInventoryGroups.js | 13 + .../ConstructedInventoryGroups.test.js | 15 + .../ConstructedInventoryGroups/index.js | 1 + .../ConstructedInventoryHosts.js | 13 + .../ConstructedInventoryHosts.test.js | 15 + .../ConstructedInventoryHosts/index.js | 1 + awx/ui/src/screens/Inventory/Inventories.js | 15 +- awx/ui/src/screens/Inventory/Inventory.js | 7 +- .../Inventory/InventoryList/InventoryList.js | 16 +- .../InventoryList/InventoryListItem.js | 29 +- .../src/screens/Inventory/SmartInventory.js | 5 +- awx/ui/src/screens/Inventory/shared/utils.js | 9 + .../screens/Inventory/shared/utils.test.js | 20 +- 27 files changed, 849 insertions(+), 28 deletions(-) create mode 100644 awx/ui/src/api/models/ConstructedInventories.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventory.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventory.test.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryAdd/index.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryDetail/index.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryEdit/index.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js diff --git a/awx/ui/src/api/index.js b/awx/ui/src/api/index.js index 7a03643c05..5876efc6f1 100644 --- a/awx/ui/src/api/index.js +++ b/awx/ui/src/api/index.js @@ -6,6 +6,7 @@ import Config from './models/Config'; import CredentialInputSources from './models/CredentialInputSources'; import CredentialTypes from './models/CredentialTypes'; import Credentials from './models/Credentials'; +import ConstructedInventories from './models/ConstructedInventories'; import Dashboard from './models/Dashboard'; import ExecutionEnvironments from './models/ExecutionEnvironments'; import Groups from './models/Groups'; @@ -54,6 +55,7 @@ const ConfigAPI = new Config(); const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialTypesAPI = new CredentialTypes(); const CredentialsAPI = new Credentials(); +const ConstructedInventoriesAPI = new ConstructedInventories(); const DashboardAPI = new Dashboard(); const ExecutionEnvironmentsAPI = new ExecutionEnvironments(); const GroupsAPI = new Groups(); @@ -103,6 +105,7 @@ export { CredentialInputSourcesAPI, CredentialTypesAPI, CredentialsAPI, + ConstructedInventoriesAPI, DashboardAPI, ExecutionEnvironmentsAPI, GroupsAPI, diff --git a/awx/ui/src/api/models/ConstructedInventories.js b/awx/ui/src/api/models/ConstructedInventories.js new file mode 100644 index 0000000000..b62bffd3f3 --- /dev/null +++ b/awx/ui/src/api/models/ConstructedInventories.js @@ -0,0 +1,11 @@ +import Base from '../Base'; +import InstanceGroupsMixin from '../mixins/InstanceGroups.mixin'; + +class ConstructedInventories extends InstanceGroupsMixin(Base) { + constructor(http) { + super(http); + this.baseUrl = 'api/v2/constructed_inventories/'; + } +} + +export default ConstructedInventories; diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index fd1653045f..37654478a7 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -13,6 +13,7 @@ class Inventories extends InstanceGroupsMixin(Base) { this.readGroups = this.readGroups.bind(this); this.readGroupsOptions = this.readGroupsOptions.bind(this); this.promoteGroup = this.promoteGroup.bind(this); + this.readSourceInventories = this.readSourceInventories.bind(this); } readAccessList(id, params) { @@ -72,6 +73,12 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } + readSourceInventories(inventoryId, params) { + return this.http.get(`${this.baseUrl}${inventoryId}/input_inventories/`, { + params, + }); + } + readSources(inventoryId, params) { return this.http.get(`${this.baseUrl}${inventoryId}/inventory_sources/`, { params, diff --git a/awx/ui/src/screens/Inventory/ConstructedInventory.js b/awx/ui/src/screens/Inventory/ConstructedInventory.js new file mode 100644 index 0000000000..58b33b96d2 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.js @@ -0,0 +1,206 @@ +import React, { useCallback, useEffect } from 'react'; +import { t } from '@lingui/macro'; +import { + Link, + Switch, + Route, + Redirect, + useRouteMatch, + useLocation, +} from 'react-router-dom'; +import { CaretLeftIcon } from '@patternfly/react-icons'; +import { Card, PageSection } from '@patternfly/react-core'; + +import useRequest from 'hooks/useRequest'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; + +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import JobList from 'components/JobList'; +import RelatedTemplateList from 'components/RelatedTemplateList'; +import { ResourceAccessList } from 'components/ResourceAccessList'; +import RoutedTabs from 'components/RoutedTabs'; +import ConstructedInventoryDetail from './ConstructedInventoryDetail'; +import ConstructedInventoryEdit from './ConstructedInventoryEdit'; +import ConstructedInventoryGroups from './ConstructedInventoryGroups'; +import ConstructedInventoryHosts from './ConstructedInventoryHosts'; +import { getInventoryPath } from './shared/utils'; + +function ConstructedInventory({ setBreadcrumb }) { + const location = useLocation(); + const match = useRouteMatch('/inventories/constructed_inventory/:id'); + + const { + result: inventory, + error: contentError, + isLoading: hasContentLoading, + request: fetchInventory, + } = useRequest( + useCallback(async () => { + const { data } = await ConstructedInventoriesAPI.readDetail( + match.params.id + ); + return data; + }, [match.params.id]), + + null + ); + + useEffect(() => { + fetchInventory(); + }, [fetchInventory, location.pathname]); + + useEffect(() => { + if (inventory) { + setBreadcrumb(inventory); + } + }, [inventory, setBreadcrumb]); + + const tabsArray = [ + { + name: ( + <> + + {t`Back to Inventories`} + + ), + link: `/inventories`, + id: 99, + }, + { name: t`Details`, link: `${match.url}/details`, id: 0 }, + { name: t`Access`, link: `${match.url}/access`, id: 1 }, + { name: t`Hosts`, link: `${match.url}/hosts`, id: 2 }, + { name: t`Groups`, link: `${match.url}/groups`, id: 3 }, + { + name: t`Jobs`, + link: `${match.url}/jobs`, + id: 4, + }, + { name: t`Job Templates`, link: `${match.url}/job_templates`, id: 5 }, + ]; + + if (hasContentLoading) { + return ( + + + + + + ); + } + + if (contentError) { + return ( + + + + {contentError?.response?.status === 404 && ( + + {t`Constructed Inventory not found.`}{' '} + {t`View all Inventories.`} + + )} + + + + ); + } + + if (inventory && inventory?.kind !== 'constructed') { + return ; + } + + let showCardHeader = true; + if (['edit'].some((name) => location.pathname.includes(name))) { + showCardHeader = false; + } + + return ( + + + {showCardHeader && } + + + {inventory && [ + + + , + + + , + + + , + + + , + + + , + + + , + + + , + ]} + + + {match.params.id && ( + + {t`View Constructed Inventory Details`} + + )} + + + + + + ); +} + +export { ConstructedInventory as _ConstructedInventory }; +export default ConstructedInventory; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventory.test.js b/awx/ui/src/screens/Inventory/ConstructedInventory.test.js new file mode 100644 index 0000000000..da4cd56c34 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.test.js @@ -0,0 +1,73 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { ConstructedInventoriesAPI } from 'api'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import mockInventory from './shared/data.inventory.json'; +import ConstructedInventory from './ConstructedInventory'; + +jest.mock('../../api'); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useRouteMatch: () => ({ + url: '/constructed_inventories/1', + params: { id: 1 }, + }), +})); + +describe('', () => { + let wrapper; + + beforeEach(async () => { + ConstructedInventoriesAPI.readDetail.mockResolvedValue({ + data: mockInventory, + }); + }); + + test('should render expected tabs', async () => { + const expectedTabs = [ + 'Back to Inventories', + 'Details', + 'Access', + 'Hosts', + 'Groups', + 'Jobs', + 'Job Templates', + ]; + await act(async () => { + wrapper = mountWithContexts( + {}} /> + ); + }); + wrapper.find('RoutedTabs li').forEach((tab, index) => { + expect(tab.text()).toEqual(expectedTabs[index]); + }); + }); + + test('should show content error when user attempts to navigate to erroneous route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/1/foobar'], + }); + await act(async () => { + wrapper = mountWithContexts( + {}} />, + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { id: 1 }, + url: '/inventories/constructed_inventory/1/foobar', + path: '/inventories/constructed_inventory/1/foobar', + }, + }, + }, + }, + } + ); + }); + expect(wrapper.find('ContentError').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js new file mode 100644 index 0000000000..1aaa2b7679 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js @@ -0,0 +1,18 @@ +/* eslint i18next/no-literal-string: "off" */ +import React from 'react'; +import { Card, PageSection } from '@patternfly/react-core'; +import { CardBody } from 'components/Card'; + +function ConstructedInventoryAdd() { + return ( + + + +
Coming Soon!
+
+
+
+ ); +} + +export default ConstructedInventoryAdd; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js new file mode 100644 index 0000000000..0a9a6eedd5 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import ConstructedInventoryAdd from './ConstructedInventoryAdd'; + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('ConstructedInventoryAdd').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/index.js new file mode 100644 index 0000000000..438115593a --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/index.js @@ -0,0 +1 @@ +export { default } from './ConstructedInventoryAdd'; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js new file mode 100644 index 0000000000..914e86b0b1 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js @@ -0,0 +1,288 @@ +import React, { useCallback, useEffect } from 'react'; +import { Link, useHistory } from 'react-router-dom'; + +import { t } from '@lingui/macro'; +import { + Button, + Chip, + TextList, + TextListItem, + TextListItemVariants, + TextListVariants, +} from '@patternfly/react-core'; +import AlertModal from 'components/AlertModal'; +import { CardBody, CardActionsRow } from 'components/Card'; +import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import { VariablesDetail } from 'components/CodeEditor'; +import DeleteButton from 'components/DeleteButton'; +import ErrorDetail from 'components/ErrorDetail'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import ChipGroup from 'components/ChipGroup'; +import Popover from 'components/Popover'; +import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; +import { Inventory } from 'types'; +import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; +import InstanceGroupLabels from 'components/InstanceGroupLabels'; +import getHelpText from '../shared/Inventory.helptext'; + +function ConstructedInventoryDetail({ inventory }) { + const history = useHistory(); + const helpText = getHelpText(); + + const { + result: { instanceGroups, sourceInventories, actions }, + request: fetchRelatedDetails, + error: contentError, + isLoading, + } = useRequest( + useCallback(async () => { + const [response, sourceInvResponse, options] = await Promise.all([ + InventoriesAPI.readInstanceGroups(inventory.id), + InventoriesAPI.readSourceInventories(inventory.id), + ConstructedInventoriesAPI.readOptions(inventory.id), + ]); + + return { + instanceGroups: response.data.results, + sourceInventories: sourceInvResponse.data.results, + actions: options.data.actions.GET, + }; + }, [inventory.id]), + { + instanceGroups: [], + sourceInventories: [], + actions: {}, + isLoading: true, + } + ); + + useEffect(() => { + fetchRelatedDetails(); + }, [fetchRelatedDetails]); + + const { request: deleteInventory, error: deleteError } = useRequest( + useCallback(async () => { + await InventoriesAPI.destroy(inventory.id); + history.push(`/inventories`); + }, [inventory.id, history]) + ); + + const { error, dismissError } = useDismissableError(deleteError); + + const { organization, user_capabilities: userCapabilities } = + inventory.summary_fields; + + const deleteDetailsRequests = + relatedResourceDeleteRequests.inventory(inventory); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } + + return ( + + + + + + + + {organization.name} + + } + /> + + + + + + + {instanceGroups && ( + } + isEmpty={instanceGroups.length === 0} + dataCy="constructed-inventory-instance-groups" + /> + )} + {inventory.prevent_instance_group_fallback && ( + + {inventory.prevent_instance_group_fallback && ( + + {t`Prevent Instance Group Fallback`} + + + )} + + } + /> + )} + + {inventory.summary_fields.labels?.results?.map((l) => ( + + {l.name} + + ))} + + } + isEmpty={inventory.summary_fields.labels?.results?.length === 0} + /> + + {sourceInventories?.map((sourceInventory) => ( + + + {sourceInventory.name} + + + ))} + + } + isEmpty={sourceInventories?.length === 0} + /> + + + + + + {userCapabilities.edit && ( + + )} + {userCapabilities.delete && ( + + {t`Delete`} + + )} + + {error && ( + + {t`Failed to delete inventory.`} + + + )} + + ); +} + +ConstructedInventoryDetail.propTypes = { + inventory: Inventory.isRequired, +}; + +export default ConstructedInventoryDetail; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js new file mode 100644 index 0000000000..5d924a2790 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js @@ -0,0 +1,66 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { InventoriesAPI, CredentialTypesAPI } from 'api'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import ConstructedInventoryDetail from './ConstructedInventoryDetail'; + +jest.mock('../../../api'); + +const mockInventory = { + id: 1, + type: 'inventory', + summary_fields: { + organization: { + id: 1, + name: 'The Organization', + description: '', + }, + created_by: { + username: 'the_creator', + id: 2, + }, + modified_by: { + username: 'the_modifier', + id: 3, + }, + user_capabilities: { + edit: true, + delete: true, + copy: true, + adhoc: true, + }, + }, + created: '2019-10-04T16:56:48.025455Z', + modified: '2019-10-04T16:56:48.025468Z', + name: 'Constructed Inv', + description: '', + organization: 1, + kind: 'constructed', + has_active_failures: false, + total_hosts: 0, + hosts_with_active_failures: 0, + total_groups: 0, + groups_with_active_failures: 0, + has_inventory_sources: false, + total_inventory_sources: 0, + inventory_sources_with_failures: 0, + pending_deletion: false, + prevent_instance_group_fallback: false, + update_cache_timeout: 0, + limit: '', + verbosity: 1, +}; + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('ConstructedInventoryDetail').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/index.js new file mode 100644 index 0000000000..efe8b49508 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/index.js @@ -0,0 +1 @@ +export { default } from './ConstructedInventoryDetail'; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js new file mode 100644 index 0000000000..a49e7eaaed --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js @@ -0,0 +1,13 @@ +/* eslint i18next/no-literal-string: "off" */ +import React from 'react'; +import { CardBody } from 'components/Card'; + +function ConstructedInventoryEdit() { + return ( + +
Coming Soon!
+
+ ); +} + +export default ConstructedInventoryEdit; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js new file mode 100644 index 0000000000..02b0747880 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import ConstructedInventoryEdit from './ConstructedInventoryEdit'; + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('ConstructedInventoryEdit').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/index.js new file mode 100644 index 0000000000..55030e87ab --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/index.js @@ -0,0 +1 @@ +export { default } from './ConstructedInventoryEdit'; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js new file mode 100644 index 0000000000..964dfa9062 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js @@ -0,0 +1,13 @@ +/* eslint i18next/no-literal-string: "off" */ +import React from 'react'; +import { CardBody } from 'components/Card'; + +function ConstructedInventoryGroups() { + return ( + +
Coming Soon!
+
+ ); +} + +export default ConstructedInventoryGroups; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js new file mode 100644 index 0000000000..db2720ff44 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import ConstructedInventoryGroups from './ConstructedInventoryGroups'; + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('ConstructedInventoryGroups').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js new file mode 100644 index 0000000000..7f1b4343b2 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js @@ -0,0 +1 @@ +export { default } from './ConstructedInventoryGroups'; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js new file mode 100644 index 0000000000..56f0c801b8 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js @@ -0,0 +1,13 @@ +/* eslint i18next/no-literal-string: "off" */ +import React from 'react'; +import { CardBody } from 'components/Card'; + +function ConstructedInventoryHosts() { + return ( + +
Coming Soon!
+
+ ); +} + +export default ConstructedInventoryHosts; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js new file mode 100644 index 0000000000..0d6b3d6f13 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import ConstructedInventoryHosts from './ConstructedInventoryHosts'; + +describe('', () => { + test('initially renders successfully', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.length).toBe(1); + expect(wrapper.find('ConstructedInventoryHosts').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js new file mode 100644 index 0000000000..68464720fb --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js @@ -0,0 +1 @@ +export { default } from './ConstructedInventoryHosts'; diff --git a/awx/ui/src/screens/Inventory/Inventories.js b/awx/ui/src/screens/Inventory/Inventories.js index 49bf4d7710..dfb04a0229 100644 --- a/awx/ui/src/screens/Inventory/Inventories.js +++ b/awx/ui/src/screens/Inventory/Inventories.js @@ -9,14 +9,18 @@ import PersistentFilters from 'components/PersistentFilters'; import { InventoryList } from './InventoryList'; import Inventory from './Inventory'; import SmartInventory from './SmartInventory'; +import ConstructedInventory from './ConstructedInventory'; import InventoryAdd from './InventoryAdd'; import SmartInventoryAdd from './SmartInventoryAdd'; +import ConstructedInventoryAdd from './ConstructedInventoryAdd'; +import { getInventoryPath } from './shared/utils'; function Inventories() { const initScreenHeader = useRef({ '/inventories': t`Inventories`, '/inventories/inventory/add': t`Create new inventory`, '/inventories/smart_inventory/add': t`Create new smart inventory`, + '/inventories/constructed_inventory/add': t`Create new constructed inventory`, }); const [breadcrumbConfig, setScreenHeader] = useState( @@ -45,10 +49,7 @@ function Inventories() { return; } - const inventoryKind = - inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'; - - const inventoryPath = `/inventories/${inventoryKind}/${inventory.id}`; + const inventoryPath = getInventoryPath(inventory); const inventoryHostsPath = `${inventoryPath}/hosts`; const inventoryGroupsPath = `${inventoryPath}/groups`; const inventorySourcesPath = `${inventoryPath}/sources`; @@ -109,6 +110,9 @@ function Inventories() { + + + {({ me }) => ( @@ -119,6 +123,9 @@ function Inventories() { + + + diff --git a/awx/ui/src/screens/Inventory/Inventory.js b/awx/ui/src/screens/Inventory/Inventory.js index 53da122cd6..c35a92d375 100644 --- a/awx/ui/src/screens/Inventory/Inventory.js +++ b/awx/ui/src/screens/Inventory/Inventory.js @@ -23,6 +23,7 @@ import InventoryEdit from './InventoryEdit'; import InventoryGroups from './InventoryGroups'; import InventoryHosts from './InventoryHosts/InventoryHosts'; import InventorySources from './InventorySources'; +import { getInventoryPath } from './shared/utils'; function Inventory({ setBreadcrumb }) { const [contentError, setContentError] = useState(null); @@ -111,10 +112,8 @@ function Inventory({ setBreadcrumb }) { showCardHeader = false; } - if (inventory?.kind === 'smart') { - return ( - - ); + if (inventory && inventory?.kind !== '') { + return ; } return ( diff --git a/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js b/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js index 0ad6dcc01b..aac6071260 100644 --- a/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js +++ b/awx/ui/src/screens/Inventory/InventoryList/InventoryList.js @@ -135,6 +135,7 @@ function InventoryList() { const addInventory = t`Add inventory`; const addSmartInventory = t`Add smart inventory`; + const addConstructedInventory = t`Add constructed inventory`; const addButton = ( {addSmartInventory} , + + {addConstructedInventory} + , ]} /> ); @@ -185,6 +195,7 @@ function InventoryList() { options: [ ['', t`Inventory`], ['smart', t`Smart Inventory`], + ['constructed', t`Constructed Inventory`], ], }, { @@ -261,11 +272,6 @@ function InventoryList() { inventory={inventory} rowIndex={index} fetchInventories={fetchInventories} - detailUrl={ - inventory.kind === 'smart' - ? `${match.url}/smart_inventory/${inventory.id}/details` - : `${match.url}/inventory/${inventory.id}/details` - } onSelect={() => { if (!inventory.pending_deletion) { handleSelect(inventory); diff --git a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js index c692c32f51..3828401045 100644 --- a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js +++ b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { string, bool, func } from 'prop-types'; +import { bool, func } from 'prop-types'; import { Button, Label } from '@patternfly/react-core'; import { Tr, Td } from '@patternfly/react-table'; @@ -12,6 +12,7 @@ import { Inventory } from 'types'; import { ActionsTd, ActionItem, TdBreakWord } from 'components/PaginatedTable'; import CopyButton from 'components/CopyButton'; import StatusLabel from 'components/StatusLabel'; +import { getInventoryPath } from '../shared/utils'; function InventoryListItem({ inventory, @@ -19,12 +20,10 @@ function InventoryListItem({ isSelected, onSelect, onCopy, - detailUrl, fetchInventories, }) { InventoryListItem.propTypes = { inventory: Inventory.isRequired, - detailUrl: string.isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; @@ -50,6 +49,12 @@ function InventoryListItem({ const labelId = `check-action-${inventory.id}`; + const typeLabel = { + '': t`Inventory`, + smart: t`Smart Inventory`, + constructed: t`Constructed Inventory`, + }; + let syncStatus = 'disabled'; if (inventory.isSourceSyncRunning) { syncStatus = 'syncing'; @@ -93,16 +98,20 @@ function InventoryListItem({ {inventory.pending_deletion ? ( {inventory.name} ) : ( - + {inventory.name} )} - {inventory.kind !== 'smart' && + {inventory.kind === '' && (inventory.has_inventory_sources ? ( ))} - - {inventory.kind === 'smart' ? t`Smart Inventory` : t`Inventory`} - + {typeLabel[inventory.kind]} diff --git a/awx/ui/src/screens/Inventory/SmartInventory.js b/awx/ui/src/screens/Inventory/SmartInventory.js index 952cf5dc31..b91d253dc6 100644 --- a/awx/ui/src/screens/Inventory/SmartInventory.js +++ b/awx/ui/src/screens/Inventory/SmartInventory.js @@ -23,6 +23,7 @@ import RelatedTemplateList from 'components/RelatedTemplateList'; import SmartInventoryDetail from './SmartInventoryDetail'; import SmartInventoryEdit from './SmartInventoryEdit'; import SmartInventoryHosts from './SmartInventoryHosts'; +import { getInventoryPath } from './shared/utils'; function SmartInventory({ setBreadcrumb }) { const location = useLocation(); @@ -101,8 +102,8 @@ function SmartInventory({ setBreadcrumb }) { ); } - if (inventory?.kind === '') { - return ; + if (inventory && inventory?.kind !== 'smart') { + return ; } let showCardHeader = true; diff --git a/awx/ui/src/screens/Inventory/shared/utils.js b/awx/ui/src/screens/Inventory/shared/utils.js index c08710327f..5e335beeed 100644 --- a/awx/ui/src/screens/Inventory/shared/utils.js +++ b/awx/ui/src/screens/Inventory/shared/utils.js @@ -8,3 +8,12 @@ const parseHostFilter = (value) => { return value; }; export default parseHostFilter; + +export function getInventoryPath(inventory) { + const url = { + '': `/inventories/inventory/${inventory.id}`, + smart: `/inventories/smart_inventory/${inventory.id}`, + constructed: `/inventories/constructed_inventory/${inventory.id}`, + }; + return url[inventory.kind]; +} diff --git a/awx/ui/src/screens/Inventory/shared/utils.test.js b/awx/ui/src/screens/Inventory/shared/utils.test.js index 4d659932f7..ccbf44aff1 100644 --- a/awx/ui/src/screens/Inventory/shared/utils.test.js +++ b/awx/ui/src/screens/Inventory/shared/utils.test.js @@ -1,4 +1,4 @@ -import parseHostFilter from './utils'; +import parseHostFilter, { getInventoryPath } from './utils'; describe('parseHostFilter', () => { test('parse host filter', () => { @@ -19,3 +19,21 @@ describe('parseHostFilter', () => { }); }); }); + +describe('getInventoryPath', () => { + test('should return inventory path', () => { + expect(getInventoryPath({ id: 1, kind: '' })).toMatch( + '/inventories/inventory/1' + ); + }); + test('should return smart inventory path', () => { + expect(getInventoryPath({ id: 2, kind: 'smart' })).toMatch( + '/inventories/smart_inventory/2' + ); + }); + test('should return constructed inventory path', () => { + expect(getInventoryPath({ id: 3, kind: 'constructed' })).toMatch( + '/inventories/constructed_inventory/3' + ); + }); +}); From 7112da9cdcc85882232049ffef83c2165e142c23 Mon Sep 17 00:00:00 2001 From: Gabe Muniz Date: Thu, 9 Feb 2023 12:56:33 -0500 Subject: [PATCH 12/33] Various validations for const. inv. serialization - prevent constructed inventory host,group,inventory_source creation - disable deleting constructed inventory hosts - remove the ability to add constructed inventory sources - remove ability to add constructed inventories to constructed inventories - block updates to constructed source type - added tests for group/host/source creation --- awx/api/serializers.py | 16 +++-- awx/api/views/__init__.py | 2 + awx/api/views/inventory.py | 9 +++ .../test_inventory_input_constructed.py | 70 +++++++++++++++++++ 4 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 awx/main/tests/functional/test_inventory_input_constructed.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c898bf754f..d02a486f5c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1933,8 +1933,8 @@ class HostSerializer(BaseSerializerWithVariables): return value def validate_inventory(self, value): - if value.kind == 'smart': - raise serializers.ValidationError({"detail": _("Cannot create Host for Smart Inventory")}) + if value.kind in ('constructed', 'smart'): + raise serializers.ValidationError({"detail": _("Cannot create Host for Smart or Constructed Inventories")}) return value def validate_variables(self, value): @@ -2032,8 +2032,8 @@ class GroupSerializer(BaseSerializerWithVariables): return value def validate_inventory(self, value): - if value.kind == 'smart': - raise serializers.ValidationError({"detail": _("Cannot create Group for Smart Inventory")}) + if value.kind in ('constructed', 'smart'): + raise serializers.ValidationError({"detail": _("Cannot create Group for Smart or Constructed Inventories")}) return value def to_representation(self, obj): @@ -2339,8 +2339,8 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt return value def validate_inventory(self, value): - if value and value.kind == 'smart': - raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart Inventory")}) + if value and value.kind in ('constructed', 'smart'): + raise serializers.ValidationError({"detail": _("Cannot create Inventory Source for Smart or Constructed Inventories")}) return value # TODO: remove when old 'credential' fields are removed @@ -2361,6 +2361,8 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt obj = super(InventorySourceSerializer, self).update(obj, validated_data) if deprecated_fields: self._update_deprecated_fields(deprecated_fields, obj) + if obj.source == 'constructed': + raise serializers.ValidationError({'error': _("Cannot edit source of type constructed.")}) return obj # TODO: remove when old 'credential' fields are removed @@ -2387,6 +2389,8 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt if get_field_from_model_or_attrs('source') == 'scm': if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None: raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")}) + elif (get_field_from_model_or_attrs('source') == 'constructed') and (self.instance and self.instance.source != 'constructed'): + raise serializers.ValidationError({"Error": _('constructed not a valid source for inventory')}) else: redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path', 'scm_branch'])) if redundant_scm_fields: diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index c1e99a4002..d023f92984 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -1610,6 +1610,8 @@ class HostDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): def delete(self, request, *args, **kwargs): if self.get_object().inventory.pending_deletion: return Response({"error": _("The inventory for this host is already being deleted.")}, status=status.HTTP_400_BAD_REQUEST) + if self.get_object().inventory.kind == 'constructed': + return Response({"error": _("Delete constructed inventory hosts from input inventory.")}, status=status.HTTP_400_BAD_REQUEST) return super(HostDetail, self).delete(request, *args, **kwargs) diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 64550e11c5..453f9c6072 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -115,6 +115,15 @@ class InventoryInputInventoriesList(SubListAttachDetachAPIView): parent_model = Inventory relationship = 'input_inventories' + # Specifically overriding the post method on this view to disallow constructed inventories as input inventories + def post(self, request, *args, **kwargs): + obj = Inventory.objects.get(id=request.data.get('id')) + if obj.kind == 'constructed': + return Response( + dict(error=_('You cannot add a constructed inventory to another constructed inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED + ) + return super(InventoryInputInventoriesList, self).post(request, *args, **kwargs) + class InventoryActivityStreamList(SubListAPIView): model = ActivityStream diff --git a/awx/main/tests/functional/test_inventory_input_constructed.py b/awx/main/tests/functional/test_inventory_input_constructed.py new file mode 100644 index 0000000000..e677d46f46 --- /dev/null +++ b/awx/main/tests/functional/test_inventory_input_constructed.py @@ -0,0 +1,70 @@ +import pytest +from awx.main.models import Inventory +from awx.api.versioning import reverse + + +@pytest.fixture +def constructed_inventory(organization): + """ + creates a new constructed inventory source + """ + return Inventory.objects.create(name='dummy2', kind='constructed', organization=organization) + + +@pytest.mark.django_db +def test_constructed_inventory_post(post, organization, admin_user): + inventory1 = Inventory.objects.create(name='dummy1', kind='constructed', organization=organization) + inventory2 = Inventory.objects.create(name='dummy2', kind='constructed', organization=organization) + resp = post( + url=reverse('api:inventory_input_inventories', kwargs={'pk': inventory1.pk}), + data={'id': inventory2.pk}, + user=admin_user, + expect=405, + ) + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_add_constructed_inventory_source(post, admin_user, constructed_inventory): + resp = post( + url=reverse('api:inventory_inventory_sources_list', kwargs={'pk': constructed_inventory.pk}), + data={'name': 'dummy1', 'source': 'constructed'}, + user=admin_user, + expect=400, + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_add_constructed_inventory_host(post, admin_user, constructed_inventory): + resp = post( + url=reverse('api:inventory_hosts_list', kwargs={'pk': constructed_inventory.pk}), + data={'name': 'dummy1'}, + user=admin_user, + expect=400, + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_add_constructed_inventory_group(post, admin_user, constructed_inventory): + resp = post( + reverse('api:inventory_groups_list', kwargs={'pk': constructed_inventory.pk}), + data={'name': 'group-test'}, + user=admin_user, + expect=400, + ) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_edit_constructed_inventory_source(patch, admin_user, inventory): + inventory.inventory_sources.create(name="dummysrc", source="constructed") + inv_id = inventory.inventory_sources.get(name='dummysrc').id + resp = patch( + reverse('api:inventory_source_detail', kwargs={'pk': inv_id}), + data={'description': inventory.name}, + user=admin_user, + expect=400, + ) + assert resp.status_code == 400 From 8c4e943af0f4ff1e235e0c883f49a33a925e454b Mon Sep 17 00:00:00 2001 From: Gabe Muniz Date: Tue, 14 Feb 2023 00:07:49 -0500 Subject: [PATCH 13/33] refactored to use is_valid_relation instead of post --- awx/api/views/inventory.py | 12 +++---- awx/main/tests/functional/conftest.py | 8 +++++ .../test_inventory_input_constructed.py | 31 +++++++------------ 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 453f9c6072..4085cf9bff 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework import status +from rest_framework import serializers # AWX from awx.main.models import ActivityStream, Inventory, JobTemplate, Role, User, InstanceGroup, InventoryUpdateEvent, InventoryUpdate @@ -115,14 +116,9 @@ class InventoryInputInventoriesList(SubListAttachDetachAPIView): parent_model = Inventory relationship = 'input_inventories' - # Specifically overriding the post method on this view to disallow constructed inventories as input inventories - def post(self, request, *args, **kwargs): - obj = Inventory.objects.get(id=request.data.get('id')) - if obj.kind == 'constructed': - return Response( - dict(error=_('You cannot add a constructed inventory to another constructed inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED - ) - return super(InventoryInputInventoriesList, self).post(request, *args, **kwargs) + def is_valid_relation(self, parent, sub, created=False): + if sub.kind == 'constructed': + raise serializers.ValidationError({'error': 'You cannot add a constructed inventory to another constructed inventory.'}) class InventoryActivityStreamList(SubListAPIView): diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 4f8b6bc83c..e1284ce87c 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -511,6 +511,14 @@ def group(inventory): return inventory.groups.create(name='single-group') +@pytest.fixture +def constructed_inventory(organization): + """ + creates a new constructed inventory source + """ + return Inventory.objects.create(name='dummy1', kind='constructed', organization=organization) + + @pytest.fixture def inventory_source(inventory): # by making it ec2, the credential is not required diff --git a/awx/main/tests/functional/test_inventory_input_constructed.py b/awx/main/tests/functional/test_inventory_input_constructed.py index e677d46f46..2602cf2947 100644 --- a/awx/main/tests/functional/test_inventory_input_constructed.py +++ b/awx/main/tests/functional/test_inventory_input_constructed.py @@ -3,25 +3,17 @@ from awx.main.models import Inventory from awx.api.versioning import reverse -@pytest.fixture -def constructed_inventory(organization): - """ - creates a new constructed inventory source - """ - return Inventory.objects.create(name='dummy2', kind='constructed', organization=organization) - - @pytest.mark.django_db -def test_constructed_inventory_post(post, organization, admin_user): - inventory1 = Inventory.objects.create(name='dummy1', kind='constructed', organization=organization) - inventory2 = Inventory.objects.create(name='dummy2', kind='constructed', organization=organization) +def test_constructed_inventory_post(post, admin_user, organization): + inv1 = Inventory.objects.create(name='dummy1', kind='constructed', organization=organization) + inv2 = Inventory.objects.create(name='dummy2', kind='constructed', organization=organization) resp = post( - url=reverse('api:inventory_input_inventories', kwargs={'pk': inventory1.pk}), - data={'id': inventory2.pk}, + url=reverse('api:inventory_input_inventories', kwargs={'pk': inv1.pk}), + data={'id': inv2.pk}, user=admin_user, - expect=405, + expect=400, ) - assert resp.status_code == 405 + assert resp.status_code == 400 @pytest.mark.django_db @@ -58,12 +50,11 @@ def test_add_constructed_inventory_group(post, admin_user, constructed_inventory @pytest.mark.django_db -def test_edit_constructed_inventory_source(patch, admin_user, inventory): - inventory.inventory_sources.create(name="dummysrc", source="constructed") - inv_id = inventory.inventory_sources.get(name='dummysrc').id +def test_edit_constructed_inventory_source(patch, admin_user, inventory_source_factory): + inv_src = inventory_source_factory(name='dummy1', source='constructed') resp = patch( - reverse('api:inventory_source_detail', kwargs={'pk': inv_id}), - data={'description': inventory.name}, + reverse('api:inventory_source_detail', kwargs={'pk': inv_src.pk}), + data={'description': inv_src.name}, user=admin_user, expect=400, ) From 7dd1bc04c40ab868c11ca0d154b7081f5d25dd5a Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Sat, 18 Feb 2023 11:33:44 -0500 Subject: [PATCH 14/33] Add constructed inventory detail's sync button --- awx/ui/src/api/models/Inventories.js | 4 +- .../ConstructedInventoryDetail.js | 140 +++++++++--- .../ConstructedInventoryDetail.test.js | 210 ++++++++++++++++-- .../ConstructedInventorySyncButton.js | 59 +++++ .../ConstructedInventorySyncButton.test.js | 41 ++++ .../InventorySourceDetail.js | 2 +- .../useWsInventorySourcesDetails.js | 0 .../useWsInventorySourcesDetails.test.js | 0 8 files changed, 407 insertions(+), 49 deletions(-) create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.js create mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js rename awx/ui/src/screens/Inventory/{InventorySources => shared}/useWsInventorySourcesDetails.js (100%) rename awx/ui/src/screens/Inventory/{InventorySources => shared}/useWsInventorySourcesDetails.test.js (100%) diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index 37654478a7..4fd145e178 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -13,7 +13,7 @@ class Inventories extends InstanceGroupsMixin(Base) { this.readGroups = this.readGroups.bind(this); this.readGroupsOptions = this.readGroupsOptions.bind(this); this.promoteGroup = this.promoteGroup.bind(this); - this.readSourceInventories = this.readSourceInventories.bind(this); + this.readInputInventories = this.readInputInventories.bind(this); } readAccessList(id, params) { @@ -73,7 +73,7 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } - readSourceInventories(inventoryId, params) { + readInputInventories(inventoryId, params) { return this.http.get(`${this.baseUrl}${inventoryId}/input_inventories/`, { params, }); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js index 914e86b0b1..d8e136646b 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js @@ -9,50 +9,97 @@ import { TextListItem, TextListItemVariants, TextListVariants, + Tooltip, } from '@patternfly/react-core'; +import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; +import { Inventory } from 'types'; +import { formatDateString } from 'util/dates'; +import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; import AlertModal from 'components/AlertModal'; import { CardBody, CardActionsRow } from 'components/Card'; -import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import ChipGroup from 'components/ChipGroup'; import { VariablesDetail } from 'components/CodeEditor'; -import DeleteButton from 'components/DeleteButton'; -import ErrorDetail from 'components/ErrorDetail'; import ContentError from 'components/ContentError'; import ContentLoading from 'components/ContentLoading'; -import ChipGroup from 'components/ChipGroup'; -import Popover from 'components/Popover'; -import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; -import useRequest, { useDismissableError } from 'hooks/useRequest'; -import { Inventory } from 'types'; -import { relatedResourceDeleteRequests } from 'util/getRelatedResourceDeleteDetails'; +import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; +import DeleteButton from 'components/DeleteButton'; +import ErrorDetail from 'components/ErrorDetail'; import InstanceGroupLabels from 'components/InstanceGroupLabels'; +import JobCancelButton from 'components/JobCancelButton'; +import Popover from 'components/Popover'; +import StatusLabel from 'components/StatusLabel'; +import ConstructedInventorySyncButton from './ConstructedInventorySyncButton'; +import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails'; import getHelpText from '../shared/Inventory.helptext'; +function JobStatusLabel({ job }) { + if (!job) { + return null; + } + + return ( + +
{t`MOST RECENT SYNC`}
+
+ {t`JOB ID:`} {job.id} +
+
+ {t`STATUS:`} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {t`FINISHED:`} {formatDateString(job.finished)} +
+ )} + + } + key={job.id} + > + + + +
+ ); +} + function ConstructedInventoryDetail({ inventory }) { const history = useHistory(); const helpText = getHelpText(); const { - result: { instanceGroups, sourceInventories, actions }, + result: { instanceGroups, inputInventories, inventorySource, actions }, request: fetchRelatedDetails, error: contentError, isLoading, } = useRequest( useCallback(async () => { - const [response, sourceInvResponse, options] = await Promise.all([ + const [ + instanceGroupsResponse, + inputInventoriesResponse, + inventorySourceResponse, + optionsResponse, + ] = await Promise.all([ InventoriesAPI.readInstanceGroups(inventory.id), - InventoriesAPI.readSourceInventories(inventory.id), - ConstructedInventoriesAPI.readOptions(inventory.id), + InventoriesAPI.readInputInventories(inventory.id), + InventoriesAPI.readSources(inventory.id), + ConstructedInventoriesAPI.readOptions(), ]); return { - instanceGroups: response.data.results, - sourceInventories: sourceInvResponse.data.results, - actions: options.data.actions.GET, + instanceGroups: instanceGroupsResponse.data.results, + inputInventories: inputInventoriesResponse.data.results, + inventorySource: inventorySourceResponse.data.results[0], + actions: optionsResponse.data.actions.GET, }; }, [inventory.id]), { instanceGroups: [], - sourceInventories: [], + inputInventories: [], + inventorySource: {}, actions: {}, isLoading: true, } @@ -62,6 +109,12 @@ function ConstructedInventoryDetail({ inventory }) { fetchRelatedDetails(); }, [fetchRelatedDetails]); + const wsInventorySource = useWsInventorySourcesDetails(inventorySource); + const inventorySourceSyncJob = + wsInventorySource.summary_fields?.current_job || + wsInventorySource.summary_fields?.last_job || + null; + const { request: deleteInventory, error: deleteError } = useRequest( useCallback(async () => { await InventoriesAPI.destroy(inventory.id); @@ -71,9 +124,6 @@ function ConstructedInventoryDetail({ inventory }) { const { error, dismissError } = useDismissableError(deleteError); - const { organization, user_capabilities: userCapabilities } = - inventory.summary_fields; - const deleteDetailsRequests = relatedResourceDeleteRequests.inventory(inventory); @@ -93,6 +143,14 @@ function ConstructedInventoryDetail({ inventory }) { value={inventory.name} dataCy="constructed-inventory-name" /> + + ) + } + /> - {organization.name} + + {inventory.summary_fields?.organization.name} } /> @@ -204,26 +264,26 @@ function ConstructedInventoryDetail({ inventory }) { /> - {sourceInventories?.map((sourceInventory) => ( + {inputInventories?.map((inputInventory) => ( - - {sourceInventory.name} + + {inputInventory.name} ))} } - isEmpty={sourceInventories?.length === 0} + isEmpty={inputInventories?.length === 0} /> - {userCapabilities.edit && ( + {inventory?.summary_fields?.user_capabilities?.edit && ( + + {startError && ( + + {t`Failed to sync constructed inventory source`} + + + )} + + ); +} + +ConstructedInventorySyncButton.propTypes = { + inventoryId: PropTypes.number.isRequired, +}; + +export default ConstructedInventorySyncButton; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js new file mode 100644 index 0000000000..75a5900abb --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.test.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { InventoriesAPI } from 'api'; +import ConstructedInventorySyncButton from './ConstructedInventorySyncButton'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +jest.mock('../../../api'); + +const inventory = { id: 100, name: 'Constructed Inventory' }; + +describe('', () => { + const Component = () => ( + + ); + + test('should render start sync button', () => { + render(); + expect( + screen.getByRole('button', { name: 'Start inventory source sync' }) + ).toBeInTheDocument(); + }); + + test('should make expected api request on sync', async () => { + render(); + const syncButton = screen.queryByText('Sync'); + fireEvent.click(syncButton); + await waitFor(() => + expect(InventoriesAPI.syncAllSources).toHaveBeenCalledWith(100) + ); + }); + + test('should show alert modal on throw', async () => { + InventoriesAPI.syncAllSources.mockRejectedValueOnce(new Error()); + render(); + await waitFor(() => { + const syncButton = screen.queryByText('Sync'); + fireEvent.click(syncButton); + }); + expect(screen.getByRole('dialog', { name: 'Alert modal Error!' })); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js index cf5d1354af..75280a64e4 100644 --- a/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js +++ b/awx/ui/src/screens/Inventory/InventorySourceDetail/InventorySourceDetail.js @@ -31,7 +31,7 @@ import { formatDateString } from 'util/dates'; import Popover from 'components/Popover'; import { VERBOSITY } from 'components/VerbositySelectField'; import InventorySourceSyncButton from '../shared/InventorySourceSyncButton'; -import useWsInventorySourcesDetails from '../InventorySources/useWsInventorySourcesDetails'; +import useWsInventorySourcesDetails from '../shared/useWsInventorySourcesDetails'; import getHelpText from '../shared/Inventory.helptext'; function InventorySourceDetail({ inventorySource }) { diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js similarity index 100% rename from awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js rename to awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js diff --git a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js similarity index 100% rename from awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js rename to awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js From ab3a9a0364fd048d320687649ec27bf3ab135a6a Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 21 Feb 2023 17:47:56 -0500 Subject: [PATCH 15/33] Update inventory details after inventory source sync --- .../ConstructedInventoryDetail.js | 41 +++++++++------ .../shared/useWsInventorySourcesDetails.js | 50 ++++++++++++------- .../useWsInventorySourcesDetails.test.js | 24 +++++++++ 3 files changed, 82 insertions(+), 33 deletions(-) diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js index d8e136646b..6108dc2330 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js @@ -5,6 +5,8 @@ import { t } from '@lingui/macro'; import { Button, Chip, + Label, + LabelGroup, TextList, TextListItem, TextListItemVariants, @@ -114,6 +116,10 @@ function ConstructedInventoryDetail({ inventory }) { wsInventorySource.summary_fields?.current_job || wsInventorySource.summary_fields?.last_job || null; + const wsInventory = { + ...inventory, + ...wsInventorySource?.summary_fields?.inventory, + }; const { request: deleteInventory, error: deleteError } = useRequest( useCallback(async () => { @@ -180,19 +186,19 @@ function ConstructedInventoryDetail({ inventory }) { /> @@ -204,7 +210,7 @@ function ConstructedInventoryDetail({ inventory }) { /> @@ -266,22 +272,25 @@ function ConstructedInventoryDetail({ inventory }) { fullWidth label={t`Input Inventories`} value={ - + {inputInventories?.map((inputInventory) => ( - ( + + {content} + + )} > - - {inputInventory.name} - - + {inputInventory.name} + ))} - + } isEmpty={inputInventories?.length === 0} /> diff --git a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js index e93f28f58b..e010b8916a 100644 --- a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js +++ b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js @@ -1,16 +1,17 @@ import { useState, useEffect } from 'react'; import useWebsocket from 'hooks/useWebsocket'; +import { InventorySourcesAPI } from 'api'; -export default function useWsInventorySourcesDetails(initialSources) { - const [sources, setSources] = useState(initialSources); +export default function useWsInventorySourcesDetails(initialSource) { + const [source, setSource] = useState(initialSource); const lastMessage = useWebsocket({ jobs: ['status_changed'], control: ['limit_reached_1'], }); useEffect(() => { - setSources(initialSources); - }, [initialSources]); + setSource(initialSource); + }, [initialSource]); useEffect( () => { @@ -21,22 +22,37 @@ export default function useWsInventorySourcesDetails(initialSources) { ) { return; } - const updateSource = { - ...sources, - summary_fields: { - ...sources.summary_fields, - current_job: { - id: lastMessage.unified_job_id, - status: lastMessage.status, - finished: lastMessage.finished, - }, - }, - }; - setSources(updateSource); + if ( + ['successful', 'failed', 'error', 'cancelled'].includes( + lastMessage.status + ) + ) { + fetchSource(); + } + setSource(updateSource(source, lastMessage)); }, [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps ); - return sources; + async function fetchSource() { + const { data } = await InventorySourcesAPI.readDetail(source.id); + setSource(data); + } + + return source; +} + +function updateSource(source, message) { + return { + ...source, + summary_fields: { + ...source.summary_fields, + current_job: { + id: message.unified_job_id, + status: message.status, + finished: message.finished, + }, + }, + }; } diff --git a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js index 25fb97850b..d1f1e17009 100644 --- a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js +++ b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js @@ -1,9 +1,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import WS from 'jest-websocket-mock'; +import { InventorySourcesAPI } from 'api'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import useWsInventorySourceDetails from './useWsInventorySourcesDetails'; +jest.mock('../../../api/models/InventorySources'); + function TestInner() { return
; } @@ -111,6 +114,27 @@ describe('useWsProject', () => { status: 'running', finished: null, }); + + expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(0); + InventorySourcesAPI.readDetail.mockResolvedValue({ + data: {}, + }); + await act(async () => { + mockServer.send( + JSON.stringify({ + group_name: 'jobs', + inventory_id: 1, + status: 'successful', + type: 'inventory_update', + unified_job_id: 2, + unified_job_template_id: 1, + inventory_source_id: 1, + }) + ); + }); + expect(InventorySourcesAPI.readDetail).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); WS.clean(); }); }); From e7a739c3d7e65f934a3a71a4a4ced060cfe41201 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 21 Feb 2023 16:28:14 -0500 Subject: [PATCH 16/33] Creates constructed inventory host lists by reusing, and renaming smart inventory host list components. --- .../AdvancedInventoryHost.js} | 21 ++++++------ .../AdvancedInventoryHost.test.js} | 12 +++---- .../Inventory/AdvancedInventoryHost/index.js | 1 + .../AdvancedInventoryHostDetail.js} | 12 ++++--- .../AdvancedInventoryHostDetail.test.js} | 11 ++++--- .../AdvancedInventoryHostDetail/index.js | 1 + .../AdvancedInventoryHostList.js} | 18 ++++++---- .../AdvancedInventoryHostList.test.js} | 12 +++---- .../AdvancedInventoryHostListItem.js} | 28 ++++++++-------- .../AdvancedInventoryHostListItem.test.js} | 6 ++-- .../AdvancedInventoryHosts.js | 27 +++++++++++++++ .../AdvancedInventoryHosts.test.js} | 33 +++++++++++-------- .../Inventory/AdvancedInventoryHosts/index.js | 1 + .../screens/Inventory/ConstructedInventory.js | 16 ++++++--- .../Inventory/ConstructedInventory.test.js | 22 +++++++++---- .../ConstructedInventoryHosts.js | 13 -------- .../ConstructedInventoryHosts.test.js | 15 --------- .../ConstructedInventoryHosts/index.js | 1 - .../src/screens/Inventory/SmartInventory.js | 4 +-- .../Inventory/SmartInventoryHost/index.js | 1 - .../SmartInventoryHostDetail/index.js | 1 - .../SmartInventoryHosts.js | 27 --------------- .../Inventory/SmartInventoryHosts/index.js | 1 - 23 files changed, 144 insertions(+), 140 deletions(-) rename awx/ui/src/screens/Inventory/{SmartInventoryHost/SmartInventoryHost.js => AdvancedInventoryHost/AdvancedInventoryHost.js} (74%) rename awx/ui/src/screens/Inventory/{SmartInventoryHost/SmartInventoryHost.test.js => AdvancedInventoryHost/AdvancedInventoryHost.test.js} (90%) create mode 100644 awx/ui/src/screens/Inventory/AdvancedInventoryHost/index.js rename awx/ui/src/screens/Inventory/{SmartInventoryHostDetail/SmartInventoryHostDetail.js => AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.js} (80%) rename awx/ui/src/screens/Inventory/{SmartInventoryHostDetail/SmartInventoryHostDetail.test.js => AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.test.js} (80%) create mode 100644 awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/index.js rename awx/ui/src/screens/Inventory/{SmartInventoryHosts/SmartInventoryHostList.js => AdvancedInventoryHosts/AdvancedInventoryHostList.js} (87%) rename awx/ui/src/screens/Inventory/{SmartInventoryHosts/SmartInventoryHostList.test.js => AdvancedInventoryHosts/AdvancedInventoryHostList.test.js} (85%) rename awx/ui/src/screens/Inventory/{SmartInventoryHosts/SmartInventoryHostListItem.js => AdvancedInventoryHosts/AdvancedInventoryHostListItem.js} (60%) rename awx/ui/src/screens/Inventory/{SmartInventoryHosts/SmartInventoryHostListItem.test.js => AdvancedInventoryHosts/AdvancedInventoryHostListItem.test.js} (84%) create mode 100644 awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHosts.js rename awx/ui/src/screens/Inventory/{SmartInventoryHosts/SmartInventoryHosts.test.js => AdvancedInventoryHosts/AdvancedInventoryHosts.test.js} (62%) create mode 100644 awx/ui/src/screens/Inventory/AdvancedInventoryHosts/index.js delete mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js delete mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js delete mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js delete mode 100644 awx/ui/src/screens/Inventory/SmartInventoryHost/index.js delete mode 100644 awx/ui/src/screens/Inventory/SmartInventoryHostDetail/index.js delete mode 100644 awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.js delete mode 100644 awx/ui/src/screens/Inventory/SmartInventoryHosts/index.js diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHost/SmartInventoryHost.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHost/AdvancedInventoryHost.js similarity index 74% rename from awx/ui/src/screens/Inventory/SmartInventoryHost/SmartInventoryHost.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHost/AdvancedInventoryHost.js index 62de1ecf5c..1276c3bbf8 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHost/SmartInventoryHost.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHost/AdvancedInventoryHost.js @@ -8,11 +8,11 @@ import ContentLoading from 'components/ContentLoading'; import RoutedTabs from 'components/RoutedTabs'; import useRequest from 'hooks/useRequest'; import { InventoriesAPI } from 'api'; -import SmartInventoryHostDetail from '../SmartInventoryHostDetail'; +import AdvancedInventoryHostDetail from '../AdvancedInventoryHostDetail'; -function SmartInventoryHost({ inventory, setBreadcrumb }) { +function AdvancedInventoryHost({ inventory, setBreadcrumb }) { const { params, path, url } = useRouteMatch( - '/inventories/smart_inventory/:id/hosts/:hostId' + '/inventories/:inventoryType/:id/hosts/:hostId' ); const { @@ -28,7 +28,7 @@ function SmartInventoryHost({ inventory, setBreadcrumb }) { ); return response; }, [inventory.id, params.hostId]), - null + { isLoading: true } ); useEffect(() => { @@ -44,7 +44,6 @@ function SmartInventoryHost({ inventory, setBreadcrumb }) { if (error) { return ; } - const tabsArray = [ { name: ( @@ -53,7 +52,7 @@ function SmartInventoryHost({ inventory, setBreadcrumb }) { {t`Back to Hosts`} ), - link: `/inventories/smart_inventory/${inventory.id}/hosts`, + link: `/inventories/${params.inventoryType}/${inventory.id}/hosts`, id: 0, }, { @@ -72,17 +71,19 @@ function SmartInventoryHost({ inventory, setBreadcrumb }) { {!isLoading && host && ( - + - {t`View smart inventory host details`} + {params.inventoryType === 'smart_inventory' + ? t`View smart inventory host details` + : t`View constructed inventory host details`} @@ -92,4 +93,4 @@ function SmartInventoryHost({ inventory, setBreadcrumb }) { ); } -export default SmartInventoryHost; +export default AdvancedInventoryHost; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHost/SmartInventoryHost.test.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHost/AdvancedInventoryHost.test.js similarity index 90% rename from awx/ui/src/screens/Inventory/SmartInventoryHost/SmartInventoryHost.test.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHost/AdvancedInventoryHost.test.js index d3f01bd85e..345b9478b7 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHost/SmartInventoryHost.test.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHost/AdvancedInventoryHost.test.js @@ -7,14 +7,14 @@ import { waitForElement, } from '../../../../testUtils/enzymeHelpers'; import mockHost from '../shared/data.host.json'; -import SmartInventoryHost from './SmartInventoryHost'; +import AdvancedInventoryHost from './AdvancedInventoryHost'; jest.mock('../../../api'); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useRouteMatch: () => ({ params: { id: 1234, hostId: 2 }, - path: '/inventories/smart_inventory/:id/hosts/:hostId', + path: '/inventories/:inventoryType/:id/hosts/:hostId', url: '/inventories/smart_inventory/1234/hosts/2', }), })); @@ -24,7 +24,7 @@ const mockSmartInventory = { name: 'Mock Smart Inventory', }; -describe('', () => { +describe('', () => { let wrapper; let history; @@ -36,7 +36,7 @@ describe('', () => { InventoriesAPI.readHostDetail.mockResolvedValue(mockHost); await act(async () => { wrapper = mountWithContexts( - {}} /> @@ -55,7 +55,7 @@ describe('', () => { InventoriesAPI.readHostDetail.mockRejectedValueOnce(new Error()); await act(async () => { wrapper = mountWithContexts( - {}} /> @@ -76,7 +76,7 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - {}} />, diff --git a/awx/ui/src/screens/Inventory/AdvancedInventoryHost/index.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHost/index.js new file mode 100644 index 0000000000..24b8579c1b --- /dev/null +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHost/index.js @@ -0,0 +1 @@ +export { default } from './AdvancedInventoryHost'; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHostDetail/SmartInventoryHostDetail.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.js similarity index 80% rename from awx/ui/src/screens/Inventory/SmartInventoryHostDetail/SmartInventoryHostDetail.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.js index 27af396135..afe2836663 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHostDetail/SmartInventoryHostDetail.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { t } from '@lingui/macro'; import { Host } from 'types'; @@ -8,7 +8,8 @@ import { Detail, DetailList, UserDateDetail } from 'components/DetailList'; import Sparkline from 'components/Sparkline'; import { VariablesDetail } from 'components/CodeEditor'; -function SmartInventoryHostDetail({ host }) { +function AdvancedInventoryHostDetail({ host }) { + const { inventoryType } = useParams(); const { created, description, @@ -24,6 +25,7 @@ function SmartInventoryHostDetail({ host }) { type: 'job', })); + const inventoryKind = inventory.kind === '' ? 'inventory' : inventoryType; return ( @@ -37,7 +39,7 @@ function SmartInventoryHostDetail({ host }) { + {inventory?.name} } @@ -61,8 +63,8 @@ function SmartInventoryHostDetail({ host }) { ); } -SmartInventoryHostDetail.propTypes = { +AdvancedInventoryHostDetail.propTypes = { host: Host.isRequired, }; -export default SmartInventoryHostDetail; +export default AdvancedInventoryHostDetail; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHostDetail/SmartInventoryHostDetail.test.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.test.js similarity index 80% rename from awx/ui/src/screens/Inventory/SmartInventoryHostDetail/SmartInventoryHostDetail.test.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.test.js index 93a6092b80..11c7ec7920 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHostDetail/SmartInventoryHostDetail.test.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/AdvancedInventoryHostDetail.test.js @@ -1,15 +1,17 @@ import React from 'react'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import SmartInventoryHostDetail from './SmartInventoryHostDetail'; +import AdvancedInventoryHostDetail from './AdvancedInventoryHostDetail'; import mockHost from '../shared/data.host.json'; jest.mock('../../../api'); -describe('', () => { +describe('', () => { let wrapper; beforeAll(() => { - wrapper = mountWithContexts(); + wrapper = mountWithContexts( + + ); }); test('should render Details', () => { @@ -30,11 +32,12 @@ describe('', () => { test('should not load Activity', () => { wrapper = mountWithContexts( - diff --git a/awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/index.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/index.js new file mode 100644 index 0000000000..7162875c23 --- /dev/null +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHostDetail/index.js @@ -0,0 +1 @@ +export { default } from './AdvancedInventoryHostDetail'; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostList.js similarity index 87% rename from awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostList.js index 747e7bd058..8406c439e9 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostList.js @@ -13,7 +13,7 @@ import { getQSConfig, parseQueryString } from 'util/qs'; import { InventoriesAPI } from 'api'; import { Inventory } from 'types'; import AdHocCommands from 'components/AdHocCommands/AdHocCommands'; -import SmartInventoryHostListItem from './SmartInventoryHostListItem'; +import AdvancedInventoryHostListItem from './AdvancedInventoryHostListItem'; const QS_CONFIG = getQSConfig('host', { page: 1, @@ -21,7 +21,7 @@ const QS_CONFIG = getQSConfig('host', { order_by: 'name', }); -function SmartInventoryHostList({ inventory }) { +function AdvancedInventoryHostList({ inventory }) { const location = useLocation(); const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const { @@ -61,7 +61,10 @@ function SmartInventoryHostList({ inventory }) { useEffect(() => { fetchHosts(); }, [fetchHosts]); - + const inventoryType = + inventory.kind === 'constructed' + ? 'constructed_inventory' + : 'smart_inventory'; return ( } renderRow={(host, index) => ( - row.id === host.id)} onSelect={() => handleSelect(host)} rowIndex={index} @@ -127,8 +131,8 @@ function SmartInventoryHostList({ inventory }) { ); } -SmartInventoryHostList.propTypes = { +AdvancedInventoryHostList.propTypes = { inventory: Inventory.isRequired, }; -export default SmartInventoryHostList; +export default AdvancedInventoryHostList; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostList.test.js similarity index 85% rename from awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostList.test.js index 0b87981836..f51befdaf2 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostList.test.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostList.test.js @@ -5,13 +5,13 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import SmartInventoryHostList from './SmartInventoryHostList'; +import AdvancedInventoryHostList from './AdvancedInventoryHostList'; import mockInventory from '../shared/data.inventory.json'; import mockHosts from '../shared/data.hosts.json'; jest.mock('../../../api'); -describe('', () => { +describe('', () => { let wrapper; const clonedInventory = { ...mockInventory, @@ -44,7 +44,7 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + ); }); await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); @@ -55,12 +55,12 @@ describe('', () => { }); test('initially renders successfully', () => { - expect(wrapper.find('SmartInventoryHostList').length).toBe(1); + expect(wrapper.find('AdvancedInventoryHostList').length).toBe(1); }); test('should fetch hosts from api and render them in the list', () => { expect(InventoriesAPI.readHosts).toHaveBeenCalled(); - expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3); + expect(wrapper.find('AdvancedInventoryHostListItem').length).toBe(3); }); test('should select and deselect all items', async () => { @@ -87,7 +87,7 @@ describe('', () => { ); await act(async () => { wrapper = mountWithContexts( - + ); }); await waitForElement(wrapper, 'ContentError', (el) => el.length === 1); diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostListItem.js similarity index 60% rename from awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostListItem.js index ae5fe8aab3..8030a7f5d7 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostListItem.js @@ -9,20 +9,26 @@ import { Tr, Td } from '@patternfly/react-table'; import Sparkline from 'components/Sparkline'; import { Host } from 'types'; -function SmartInventoryHostListItem({ +function AdvancedInventoryHostListItem({ detailUrl, - host, + host: { + name, + id, + summary_fields: { recent_jobs, inventory }, + }, isSelected, onSelect, rowIndex, + inventoryType, }) { - const recentPlaybookJobs = host.summary_fields.recent_jobs.map((job) => ({ + const recentPlaybookJobs = recent_jobs.map((job) => ({ ...job, type: 'job', })); - + const inventoryKind = inventory.kind === '' ? 'inventory' : inventoryType; + const inventoryLink = `/inventories/${inventoryKind}/${inventory.id}/details`; return ( - + - {host.name} + {name} - - {host.summary_fields.inventory.name} - + {inventory.name} ); } -SmartInventoryHostListItem.propTypes = { +AdvancedInventoryHostListItem.propTypes = { detailUrl: string.isRequired, host: Host.isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; -export default SmartInventoryHostListItem; +export default AdvancedInventoryHostListItem; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostListItem.test.js similarity index 84% rename from awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostListItem.test.js index b3d26782ca..be66aaa9b4 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHostListItem.test.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHostListItem.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import SmartInventoryHostListItem from './SmartInventoryHostListItem'; +import AdvancedInventoryHostListItem from './AdvancedInventoryHostListItem'; const mockHost = { id: 2, @@ -19,14 +19,14 @@ const mockHost = { }, }; -describe('', () => { +describe('', () => { let wrapper; beforeEach(() => { wrapper = mountWithContexts( - + + + + + + + + ); +} + +AdvancedInventoryHosts.propTypes = { + inventory: Inventory.isRequired, +}; + +export default AdvancedInventoryHosts; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHosts.test.js similarity index 62% rename from awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.js rename to awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHosts.test.js index f97b3c73d0..049bc0873c 100644 --- a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.test.js +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/AdvancedInventoryHosts.test.js @@ -5,37 +5,39 @@ import { mountWithContexts, waitForElement, } from '../../../../testUtils/enzymeHelpers'; -import SmartInventoryHosts from './SmartInventoryHosts'; +import AdvancedInventoryHosts from './AdvancedInventoryHosts'; jest.mock('../../../api'); -jest.mock('./SmartInventoryHostList', () => { - const SmartInventoryHostList = () =>
; +jest.mock('./AdvancedInventoryHostList', () => { + const AdvancedInventoryHostList = () =>
; return { __esModule: true, - default: SmartInventoryHostList, + default: AdvancedInventoryHostList, }; }); -describe('', () => { +describe('', () => { test('should render smart inventory host list', () => { const history = createMemoryHistory({ initialEntries: ['/inventories/smart_inventory/1/hosts'], }); const match = { - path: '/inventories/smart_inventory/:id/hosts', + path: '/inventories/:inventoryType/:id/hosts', url: '/inventories/smart_inventory/1/hosts', isExact: true, }; const wrapper = mountWithContexts( - , + , { context: { router: { history, route: { match } } }, } ); - expect(wrapper.find('SmartInventoryHostList').length).toBe(1); - expect(wrapper.find('SmartInventoryHostList').prop('inventory')).toEqual({ - id: 1, - }); + expect(wrapper.find('AdvancedInventoryHostList').length).toBe(1); + expect(wrapper.find('AdvancedInventoryHostList').prop('inventory')).toEqual( + { + id: 1, + } + ); jest.clearAllMocks(); }); @@ -45,20 +47,23 @@ describe('', () => { initialEntries: ['/inventories/smart_inventory/1/hosts/2'], }); const match = { - path: '/inventories/smart_inventory/:id/hosts/:hostId', + path: '/inventories/:inventoryType/:id/hosts/:hostId', url: '/inventories/smart_inventory/1/hosts/2', isExact: true, }; await act(async () => { wrapper = mountWithContexts( - {}} />, + {}} + />, { context: { router: { history, route: { match } } }, } ); }); await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - expect(wrapper.find('SmartInventoryHost').length).toBe(1); + expect(wrapper.find('AdvancedInventoryHost').length).toBe(1); jest.clearAllMocks(); }); }); diff --git a/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/index.js b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/index.js new file mode 100644 index 0000000000..121af5d8c2 --- /dev/null +++ b/awx/ui/src/screens/Inventory/AdvancedInventoryHosts/index.js @@ -0,0 +1 @@ +export { default } from './AdvancedInventoryHosts'; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventory.js b/awx/ui/src/screens/Inventory/ConstructedInventory.js index 58b33b96d2..242cbab585 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventory.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.js @@ -23,7 +23,7 @@ import RoutedTabs from 'components/RoutedTabs'; import ConstructedInventoryDetail from './ConstructedInventoryDetail'; import ConstructedInventoryEdit from './ConstructedInventoryEdit'; import ConstructedInventoryGroups from './ConstructedInventoryGroups'; -import ConstructedInventoryHosts from './ConstructedInventoryHosts'; +import AdvancedInventoryHosts from './AdvancedInventoryHosts'; import { getInventoryPath } from './shared/utils'; function ConstructedInventory({ setBreadcrumb }) { @@ -42,8 +42,7 @@ function ConstructedInventory({ setBreadcrumb }) { ); return data; }, [match.params.id]), - - null + { isLoading: true } ); useEffect(() => { @@ -111,7 +110,11 @@ function ConstructedInventory({ setBreadcrumb }) { } let showCardHeader = true; - if (['edit'].some((name) => location.pathname.includes(name))) { + if ( + ['edit', 'add', 'groups/', 'hosts/'].some((name) => + location.pathname.includes(name) + ) + ) { showCardHeader = false; } @@ -154,7 +157,10 @@ function ConstructedInventory({ setBreadcrumb }) { path="/inventories/constructed_inventory/:id/hosts" key="hosts" > - + , ({ describe('', () => { let wrapper; - beforeEach(async () => { + // beforeEach(async () => { + // ConstructedInventoriesAPI.readDetail.mockResolvedValue({ + // data: mockInventory, + // }); + // }); + + test('should render expected tabs', async () => { ConstructedInventoriesAPI.readDetail.mockResolvedValue({ data: mockInventory, }); - }); - - test('should render expected tabs', async () => { const expectedTabs = [ 'Back to Inventories', 'Details', @@ -45,6 +51,9 @@ describe('', () => { }); test('should show content error when user attempts to navigate to erroneous route', async () => { + ConstructedInventoriesAPI.readDetail.mockResolvedValue({ + data: { ...mockInventory, kind: 'constructed' }, + }); const history = createMemoryHistory({ initialEntries: ['/inventories/constructed_inventory/1/foobar'], }); @@ -60,7 +69,7 @@ describe('', () => { match: { params: { id: 1 }, url: '/inventories/constructed_inventory/1/foobar', - path: '/inventories/constructed_inventory/1/foobar', + path: '/inventories/:inventoryType/:id/foobar', }, }, }, @@ -68,6 +77,7 @@ describe('', () => { } ); }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); expect(wrapper.find('ContentError').length).toBe(1); }); }); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js deleted file mode 100644 index 56f0c801b8..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.js +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint i18next/no-literal-string: "off" */ -import React from 'react'; -import { CardBody } from 'components/Card'; - -function ConstructedInventoryHosts() { - return ( - -
Coming Soon!
-
- ); -} - -export default ConstructedInventoryHosts; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js deleted file mode 100644 index 0d6b3d6f13..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/ConstructedInventoryHosts.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import ConstructedInventoryHosts from './ConstructedInventoryHosts'; - -describe('', () => { - test('initially renders successfully', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryHosts').length).toBe(1); - }); -}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js deleted file mode 100644 index 68464720fb..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryHosts/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ConstructedInventoryHosts'; diff --git a/awx/ui/src/screens/Inventory/SmartInventory.js b/awx/ui/src/screens/Inventory/SmartInventory.js index b91d253dc6..d55327aa27 100644 --- a/awx/ui/src/screens/Inventory/SmartInventory.js +++ b/awx/ui/src/screens/Inventory/SmartInventory.js @@ -22,7 +22,7 @@ import RoutedTabs from 'components/RoutedTabs'; import RelatedTemplateList from 'components/RelatedTemplateList'; import SmartInventoryDetail from './SmartInventoryDetail'; import SmartInventoryEdit from './SmartInventoryEdit'; -import SmartInventoryHosts from './SmartInventoryHosts'; +import AdvancedInventoryHosts from './AdvancedInventoryHosts'; import { getInventoryPath } from './shared/utils'; function SmartInventory({ setBreadcrumb }) { @@ -142,7 +142,7 @@ function SmartInventory({ setBreadcrumb }) { />
, - diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHost/index.js b/awx/ui/src/screens/Inventory/SmartInventoryHost/index.js deleted file mode 100644 index 7e634beb10..0000000000 --- a/awx/ui/src/screens/Inventory/SmartInventoryHost/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SmartInventoryHost'; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHostDetail/index.js b/awx/ui/src/screens/Inventory/SmartInventoryHostDetail/index.js deleted file mode 100644 index 4c166ddc01..0000000000 --- a/awx/ui/src/screens/Inventory/SmartInventoryHostDetail/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SmartInventoryHostDetail'; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.js b/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.js deleted file mode 100644 index b1f461eabc..0000000000 --- a/awx/ui/src/screens/Inventory/SmartInventoryHosts/SmartInventoryHosts.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { Route, Switch } from 'react-router-dom'; -import { Inventory } from 'types'; -import SmartInventoryHostList from './SmartInventoryHostList'; -import SmartInventoryHost from '../SmartInventoryHost'; - -function SmartInventoryHosts({ inventory, setBreadcrumb }) { - return ( - - - - - - - - - ); -} - -SmartInventoryHosts.propTypes = { - inventory: Inventory.isRequired, -}; - -export default SmartInventoryHosts; diff --git a/awx/ui/src/screens/Inventory/SmartInventoryHosts/index.js b/awx/ui/src/screens/Inventory/SmartInventoryHosts/index.js deleted file mode 100644 index 95af99ffe3..0000000000 --- a/awx/ui/src/screens/Inventory/SmartInventoryHosts/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './SmartInventoryHosts'; From ba9533f0e2d96781edf4eb225696de5ad2b96764 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 23 Feb 2023 13:43:54 -0500 Subject: [PATCH 17/33] Adds constructed inventory groups and related groups. --- .../src/api/models/ConstructedInventories.js | 1 - .../screens/Inventory/ConstructedInventory.js | 9 +- .../Inventory/ConstructedInventory.test.js | 6 - .../ConstructedInventoryGroups.js | 13 -- .../ConstructedInventoryGroups.test.js | 15 --- .../ConstructedInventoryGroups/index.js | 1 - .../InventoryGroup/InventoryGroup.js | 24 ++-- .../InventoryGroup/InventoryGroup.test.js | 82 ++++++++++-- .../InventoryGroupDetail.js | 57 ++++---- .../InventoryGroupDetail.test.js | 60 +++++++++ .../InventoryGroupHostList.js | 30 +++-- .../InventoryGroupHostList.test.js | 76 ++++++++++- .../InventoryGroupHostListItem.js | 38 +++--- .../InventoryGroupHostListItem.test.js | 98 ++++++++++---- .../InventoryGroupHosts.js | 2 +- .../InventoryGroups/InventoryGroupItem.js | 50 ++++--- .../InventoryGroupItem.test.js | 37 +++++ .../InventoryGroups/InventoryGroups.js | 9 +- .../InventoryGroups/InventoryGroupsList.js | 47 ++++--- .../InventoryGroupsList.test.js | 91 +++++++++++-- .../InventoryRelatedGroupList.js | 27 ++-- .../InventoryRelatedGroupList.test.js | 126 +++++++++++++++--- .../InventoryRelatedGroupListItem.js | 36 ++--- .../InventoryRelatedGroupListItem.test.js | 109 +++++++++++---- .../InventoryRelatedGroups.js | 4 +- 25 files changed, 765 insertions(+), 283 deletions(-) delete mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js delete mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js delete mode 100644 awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js diff --git a/awx/ui/src/api/models/ConstructedInventories.js b/awx/ui/src/api/models/ConstructedInventories.js index b62bffd3f3..d1384e915e 100644 --- a/awx/ui/src/api/models/ConstructedInventories.js +++ b/awx/ui/src/api/models/ConstructedInventories.js @@ -7,5 +7,4 @@ class ConstructedInventories extends InstanceGroupsMixin(Base) { this.baseUrl = 'api/v2/constructed_inventories/'; } } - export default ConstructedInventories; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventory.js b/awx/ui/src/screens/Inventory/ConstructedInventory.js index 242cbab585..086c755adb 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventory.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.js @@ -22,7 +22,7 @@ import { ResourceAccessList } from 'components/ResourceAccessList'; import RoutedTabs from 'components/RoutedTabs'; import ConstructedInventoryDetail from './ConstructedInventoryDetail'; import ConstructedInventoryEdit from './ConstructedInventoryEdit'; -import ConstructedInventoryGroups from './ConstructedInventoryGroups'; +import InventoryGroups from './InventoryGroups'; import AdvancedInventoryHosts from './AdvancedInventoryHosts'; import { getInventoryPath } from './shared/utils'; @@ -164,9 +164,12 @@ function ConstructedInventory({ setBreadcrumb }) { , - + , ({ describe('', () => { let wrapper; - // beforeEach(async () => { - // ConstructedInventoriesAPI.readDetail.mockResolvedValue({ - // data: mockInventory, - // }); - // }); - test('should render expected tabs', async () => { ConstructedInventoriesAPI.readDetail.mockResolvedValue({ data: mockInventory, diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js deleted file mode 100644 index 964dfa9062..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.js +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint i18next/no-literal-string: "off" */ -import React from 'react'; -import { CardBody } from 'components/Card'; - -function ConstructedInventoryGroups() { - return ( - -
Coming Soon!
-
- ); -} - -export default ConstructedInventoryGroups; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js deleted file mode 100644 index db2720ff44..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/ConstructedInventoryGroups.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; -import ConstructedInventoryGroups from './ConstructedInventoryGroups'; - -describe('', () => { - test('initially renders successfully', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); - }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryGroups').length).toBe(1); - }); -}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js b/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js deleted file mode 100644 index 7f1b4343b2..0000000000 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryGroups/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ConstructedInventoryGroups'; diff --git a/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.js b/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.js index 0a8bc79374..6a5765114f 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.js +++ b/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.js @@ -23,7 +23,7 @@ function InventoryGroup({ setBreadcrumb, inventory }) { const [inventoryGroup, setInventoryGroup] = useState(null); const [contentLoading, setContentLoading] = useState(true); const [contentError, setContentError] = useState(null); - const { id: inventoryId, groupId } = useParams(); + const { id: inventoryId, groupId, inventoryType } = useParams(); const location = useLocation(); useEffect(() => { @@ -50,22 +50,22 @@ function InventoryGroup({ setBreadcrumb, inventory }) { {t`Back to Groups`} ), - link: `/inventories/inventory/${inventory.id}/groups`, + link: `/inventories/${inventoryType}/${inventoryId}/groups`, id: 99, }, { name: t`Details`, - link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/details`, + link: `/inventories/${inventoryType}/${inventoryId}/groups/${inventoryGroup?.id}/details`, id: 0, }, { name: t`Related Groups`, - link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_groups`, + link: `/inventories/${inventoryType}/${inventoryId}/groups/${inventoryGroup?.id}/nested_groups`, id: 1, }, { name: t`Hosts`, - link: `/inventories/inventory/${inventory.id}/groups/${inventoryGroup?.id}/nested_hosts`, + link: `/inventories/${inventoryType}/${inventoryId}/groups/${inventoryGroup?.id}/nested_hosts`, id: 2, }, ]; @@ -105,32 +105,32 @@ function InventoryGroup({ setBreadcrumb, inventory }) { {showCardHeader && } {inventoryGroup && [ , , , , @@ -138,7 +138,7 @@ function InventoryGroup({ setBreadcrumb, inventory }) { {inventory && ( - + {t`View Inventory Details`} )} diff --git a/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js b/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js index ee468bf7d4..03182dba5c 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroup/InventoryGroup.test.js @@ -11,15 +11,16 @@ import { import InventoryGroup from './InventoryGroup'; jest.mock('../../../api'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 1, - groupId: 2, - }), -})); - describe('', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 1, + inventoryType: 'inventory', + }), + })); + let wrapper; let history; const inventory = { id: 1, name: 'Foo' }; @@ -41,11 +42,11 @@ describe('', () => { }, }); history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/groups/1/details'], + initialEntries: [`/inventories/inventory/1/groups/1/details`], }); await act(async () => { wrapper = mountWithContexts( - + {}} inventory={inventory} /> , { context: { router: { history } } } @@ -63,7 +64,7 @@ describe('', () => { expect(routedTabs).toHaveLength(1); const tabs = routedTabs.prop('tabsArray'); - expect(tabs[0].link).toEqual('/inventories/inventory/1/groups'); + expect(tabs[0].link).toEqual(`/inventories/inventory/1/groups`); expect(tabs[1].name).toEqual('Details'); expect(tabs[2].name).toEqual('Related Groups'); expect(tabs[3].name).toEqual('Hosts'); @@ -71,7 +72,7 @@ describe('', () => { test('should show content error when user attempts to navigate to erroneous route', async () => { history = createMemoryHistory({ - initialEntries: ['/inventories/inventory/1/groups/1/foobar'], + initialEntries: [`/inventories/inventory/1/groups/1/foobar`], }); await act(async () => { wrapper = mountWithContexts( @@ -92,3 +93,60 @@ describe('', () => { await waitForElement(wrapper, 'ContentError', (el) => el.length === 1); }); }); + +describe('constructed inventory', () => { + let wrapper; + let history; + const inventory = { id: 1, name: 'Foo' }; + + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + + beforeEach(async () => { + GroupsAPI.readDetail.mockResolvedValue({ + data: { + id: 1, + name: 'Foo', + description: 'Bar', + variables: 'bizz: buzz', + summary_fields: { + inventory: { id: 1 }, + created_by: { id: 1, username: 'Athena' }, + modified_by: { id: 1, username: 'Apollo' }, + }, + created: '2020-04-25T01:23:45.678901Z', + modified: '2020-04-25T01:23:45.678901Z', + }, + }); + history = createMemoryHistory({ + initialEntries: [`/inventories/constructed_inventory/1/groups/1/details`], + }); + + await act(async () => { + wrapper = mountWithContexts( + + {}} inventory={inventory} /> + , + { context: { router: { history } } } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + test('Constructed Inventory expect all tabs to exist, including Back to Groups', () => { + const routedTabs = wrapper.find('RoutedTabs'); + expect(routedTabs).toHaveLength(1); + + const tabs = routedTabs.prop('tabsArray'); + expect(tabs[0].link).toEqual(`/inventories/constructed_inventory/1/groups`); + expect(tabs[1].name).toEqual('Details'); + expect(tabs[2].name).toEqual('Related Groups'); + expect(tabs[3].name).toEqual('Hosts'); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js b/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js index 94fd284076..6200e49416 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupDetail/InventoryGroupDetail.js @@ -1,9 +1,8 @@ import React, { useState } from 'react'; import { t } from '@lingui/macro'; - +import { useHistory, useParams } from 'react-router-dom'; import { Button } from '@patternfly/react-core'; -import { useHistory, useParams } from 'react-router-dom'; import { VariablesDetail } from 'components/CodeEditor'; import { CardBody, CardActionsRow } from 'components/Card'; import ErrorDetail from 'components/ErrorDetail'; @@ -12,6 +11,7 @@ import { DetailList, Detail, UserDateDetail } from 'components/DetailList'; import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal'; function InventoryGroupDetail({ inventoryGroup }) { + const { inventoryType, id, groupId } = useParams(); const { summary_fields: { created_by, modified_by, user_capabilities }, created, @@ -22,7 +22,6 @@ function InventoryGroupDetail({ inventoryGroup }) { } = inventoryGroup; const [error, setError] = useState(false); const history = useHistory(); - const params = useParams(); return ( @@ -47,31 +46,33 @@ function InventoryGroupDetail({ inventoryGroup }) { user={modified_by} /> - - {user_capabilities?.edit && ( - - )} - {user_capabilities?.delete && ( - - history.push(`/inventories/inventory/${params.id}/groups`) - } - /> - )} - + {inventoryType !== 'constructed_inventory' && ( + + {user_capabilities?.edit && ( + + )} + {user_capabilities?.delete && ( + + history.push(`/inventories/inventory/${id}/groups`) + } + /> + )} + + )} {error && ( ', () => { let history; describe('User has full permissions', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 3, + inventoryType: 'inventory', + }), + })); beforeEach(async () => { await act(async () => { history = createMemoryHistory({ @@ -116,6 +124,14 @@ describe('', () => { }); describe('User has read-only permissions', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 3, + inventoryType: 'inventory', + }), + })); test('should hide edit/delete buttons', async () => { const readOnlyGroup = { ...inventoryGroup, @@ -159,4 +175,48 @@ describe('', () => { expect(wrapper.find('button[aria-label="Delete"]').length).toBe(0); }); }); + describe('Cannot edit or delete constructed inventory group', () => { + beforeEach(async () => { + await act(async () => { + history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/1/details'], + }); + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { + params: { + id: 1, + group: 2, + inventoryType: 'constructed_inventory', + }, + }, + }, + }, + }, + } + ); + await waitForElement( + wrapper, + 'ContentLoading', + (el) => el.length === 0 + ); + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + test('should not show edit button', () => { + const editButton = wrapper.find('Button[aria-label="edit"]'); + expect(editButton.length).toBe(0); + expect(wrapper.find('Button[aria-label="delete"]').length).toBe(0); + }); + }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js index 2825715ede..42903b7db6 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.js @@ -34,7 +34,7 @@ const QS_CONFIG = getQSConfig('host', { function InventoryGroupHostList() { const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); - const { id: inventoryId, groupId } = useParams(); + const { id: inventoryId, groupId, inventoryType } = useParams(); const location = useLocation(); const { @@ -145,9 +145,11 @@ function InventoryGroupHostList() { useDismissableError(associateErr); const { error: disassociateError, dismissError: dismissDisassociateError } = useDismissableError(disassociateErr); - + const isNotConstructedInventory = inventoryType !== 'constructed_inventory'; const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + actions && + Object.prototype.hasOwnProperty.call(actions, 'POST') && + isNotConstructedInventory; const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`; const addExistingHost = t`Add existing host`; const addNewHost = t`Add new host`; @@ -240,17 +242,21 @@ function InventoryGroupHostList() { />, ] : []), - , + />, + ] + : []), ]} /> )} @@ -259,8 +265,8 @@ function InventoryGroupHostList() { key={host.id} rowIndex={index} host={host} - detailUrl={`/inventories/inventory/${inventoryId}/hosts/${host.id}/details`} - editUrl={`/inventories/inventory/${inventoryId}/hosts/${host.id}/edit`} + detailUrl={`/inventories/${inventoryType}/${inventoryId}/hosts/${host.id}/details`} + editUrl={`/inventories/${inventoryType}/${inventoryId}/hosts/${host.id}/edit`} isSelected={selected.some((row) => row.id === host.id)} onSelect={() => handleSelect(host)} /> diff --git a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.js b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.js index 4205a43171..3385e38d71 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostList.test.js @@ -8,19 +8,20 @@ import { } from '../../../../testUtils/enzymeHelpers'; import InventoryGroupHostList from './InventoryGroupHostList'; import mockHosts from '../shared/data.hosts.json'; +import { Route } from 'react-router-dom'; jest.mock('../../../api/models/Groups'); jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/CredentialTypes'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 1, - groupId: 2, - }), -})); describe('', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + }), + })); let wrapper; beforeEach(async () => { @@ -303,3 +304,64 @@ describe('', () => { expect(wrapper.find('AdHocCommands')).toHaveLength(0); }); }); + +describe(' for constructed inventories', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + let wrapper; + + beforeEach(async () => { + GroupsAPI.readAllHosts.mockResolvedValue({ + data: { ...mockHosts }, + }); + InventoriesAPI.readHostsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { + module_name: { + choices: [ + ['command', 'command'], + ['shell', 'shell'], + ], + }, + }, + POST: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/1/groups/2/hosts'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { context: { router: { history } } } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + test('Should not show associate, or disassociate button', async () => { + expect(wrapper.find('AddDropDownButton').length).toBe(0); + expect(wrapper.find('DisassociateButton').length).toBe(0); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.js b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.js index b5d56925b4..f5e435c024 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.js @@ -1,6 +1,6 @@ import 'styled-components/macro'; import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { string, bool, func, number } from 'prop-types'; import { t } from '@lingui/macro'; import { Button, Tooltip } from '@patternfly/react-core'; @@ -24,7 +24,7 @@ function InventoryGroupHostListItem({ ...job, type: 'job', })); - + const { inventoryType } = useParams(); const labelId = `check-action-${host.id}`; return ( @@ -57,22 +57,24 @@ function InventoryGroupHostListItem({ > - - - - - + {inventoryType !== 'constructed_inventory' && ( + + + + + + )} ); diff --git a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.js b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.js index c26ac566f8..4667c9b02d 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHostListItem.test.js @@ -1,28 +1,35 @@ import React from 'react'; +import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import InventoryGroupHostListItem from './InventoryGroupHostListItem'; import mockHosts from '../shared/data.hosts.json'; +import { Route } from 'react-router-dom'; jest.mock('../../../api'); describe('', () => { let wrapper; const mockHost = mockHosts.results[0]; - + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/2/hosts'], + }); beforeEach(() => { wrapper = mountWithContexts( -
- - {}} - rowIndex={0} - /> - -
+ + + + {}} + rowIndex={0} + /> + +
+
, + { context: { router: { history } } } ); }); @@ -52,19 +59,60 @@ describe('', () => { const copyMockHost = { ...mockHost }; copyMockHost.summary_fields.user_capabilities.edit = false; wrapper = mountWithContexts( - - - {}} - rowIndex={0} - /> - -
+ + + + {}} + rowIndex={0} + /> + +
+
, + { context: { router: { history } } } ); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); }); }); + +describe(' inside constructed inventories', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + let wrapper; + const mockHost = mockHosts.results[0]; + const history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/1/groups/2/hosts'], + }); + beforeEach(() => { + wrapper = mountWithContexts( + + + + {}} + rowIndex={0} + /> + +
+
, + { context: { router: { history } } } + ); + }); + test('Edit button hidden for constructed inventory', () => { + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.js b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.js index d0e4c34d70..696b5bede8 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.js +++ b/awx/ui/src/screens/Inventory/InventoryGroupHosts/InventoryGroupHosts.js @@ -9,7 +9,7 @@ function InventoryGroupHosts({ inventoryGroup }) { - +
diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js index 966f4fe2a5..2f8b5b2ab4 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.js @@ -1,25 +1,20 @@ import React from 'react'; -import { bool, func, number, oneOfType, string } from 'prop-types'; +import { bool, func } from 'prop-types'; import { t } from '@lingui/macro'; import { Button } from '@patternfly/react-core'; import { Tr, Td } from '@patternfly/react-table'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { PencilAltIcon } from '@patternfly/react-icons'; import { ActionsTd, ActionItem } from 'components/PaginatedTable'; import { Group } from 'types'; -function InventoryGroupItem({ - group, - inventoryId, - isSelected, - onSelect, - rowIndex, -}) { +function InventoryGroupItem({ group, isSelected, onSelect, rowIndex }) { + const { id: inventoryId, inventoryType } = useParams(); const labelId = `check-action-${group.id}`; - const detailUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/details`; - const editUrl = `/inventories/inventory/${inventoryId}/groups/${group.id}/edit`; + const detailUrl = `/inventories/${inventoryType}/${inventoryId}/groups/${group.id}/details`; + const editUrl = `/inventories/${inventoryType}/${inventoryId}/groups/${group.id}/edit`; return ( @@ -36,29 +31,30 @@ function InventoryGroupItem({ {group.name} - - - - - + + + + )} ); } InventoryGroupItem.propTypes = { group: Group.isRequired, - inventoryId: oneOfType([number, string]).isRequired, isSelected: bool.isRequired, onSelect: func.isRequired, }; diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js index cb4956e44a..49f4fe22b5 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupItem.test.js @@ -1,4 +1,6 @@ import React from 'react'; +import { Route } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import InventoryGroupItem from './InventoryGroupItem'; @@ -57,4 +59,39 @@ describe('', () => { ); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); }); + test('edit button should be hidden from constructed inventory group', async () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ id: 42, inventoryType: 'constructed_inventory' }), + })); + const mockGroup = { + id: 2, + type: 'group', + name: 'foo', + inventory: 1, + summary_fields: { + user_capabilities: { + edit: true, + }, + }, + }; + + await act(async () => { + wrapper = mountWithContexts( + + + + {}} + /> + +
+
+ ); + }); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js index ae19f09660..97eef35dc6 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroups.js @@ -16,11 +16,14 @@ function InventoryGroups({ setBreadcrumb, inventory }) { inventory={inventory} /> - + - - + + ); diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js index 77bd67c404..fa474845c6 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.js @@ -29,7 +29,7 @@ function cannotDelete(item) { function InventoryGroupsList() { const location = useLocation(); - const { id: inventoryId } = useParams(); + const { id: inventoryId, inventoryType } = useParams(); const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const { @@ -102,9 +102,11 @@ function InventoryGroupsList() { } return t`Select a row to delete`; }; - + const isNotConstructedInventory = inventoryType !== 'constructed_inventory'; const canAdd = - actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + actions && + Object.prototype.hasOwnProperty.call(actions, 'POST') && + isNotConstructedInventory; return ( {t`Name`} - {t`Actions`} + {isNotConstructedInventory && {t`Actions`}} } renderRow={(item, index) => ( row.id === item.id)} onSelect={() => handleSelect(item)} rowIndex={index} @@ -177,20 +178,28 @@ function InventoryGroupsList() { />, ] : []), - -
- { - fetchData(); - clearSelected(); - }} - /> -
-
, + ...(isNotConstructedInventory + ? [ + +
+ { + fetchData(); + clearSelected(); + }} + /> +
+
, + ] + : []), ]} /> )} diff --git a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js index 5743fc96c8..d33127899a 100644 --- a/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js +++ b/awx/ui/src/screens/Inventory/InventoryGroups/InventoryGroupsList.test.js @@ -10,12 +10,6 @@ import { import InventoryGroupsList from './InventoryGroupsList'; jest.mock('../../../api'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 1, - }), -})); const mockGroups = [ { id: 1, @@ -60,7 +54,14 @@ const mockGroups = [ describe('', () => { let wrapper; - + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'inventory', + }), + })); beforeEach(async () => { InventoriesAPI.readGroups.mockResolvedValue({ data: { @@ -96,7 +97,7 @@ describe('', () => { }); await act(async () => { wrapper = mountWithContexts( - + , { @@ -316,3 +317,77 @@ describe(' error handling', () => { }); }); }); + +describe('Constructed Inventory group', () => { + let wrapper; + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + + beforeEach(async () => { + InventoriesAPI.readGroups.mockResolvedValue({ + data: { + count: mockGroups.length, + results: mockGroups, + }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { + module_name: { + choices: [ + ['command', 'command'], + ['shell', 'shell'], + ], + }, + }, + POST: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/3/groups'], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + test('should not show add button', () => { + expect(wrapper.find('ToolbarAddButton').length).toBe(0); + expect(wrapper.find('ToolbarDeleteButton').length).toBe(0); + expect(wrapper.find('AdHocCommands').length).toBe(1); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js index 98aa701e00..0b8ec5054d 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.js @@ -33,7 +33,7 @@ function InventoryRelatedGroupList() { const [isAdHocLaunchLoading, setIsAdHocLaunchLoading] = useState(false); const [associateError, setAssociateError] = useState(null); const [disassociateError, setDisassociateError] = useState(null); - const { id: inventoryId, groupId } = useParams(); + const { id: inventoryId, groupId, inventoryType } = useParams(); const location = useLocation(); const { @@ -69,9 +69,10 @@ function InventoryRelatedGroupList() { searchableKeys: getSearchableKeys(actions.data.actions?.GET), canAdd: actions.data.actions && - Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST'), + Object.prototype.hasOwnProperty.call(actions.data.actions, 'POST') && + inventoryType !== 'constructed_inventory', }; - }, [groupId, location.search, inventoryId]), + }, [groupId, location.search, inventoryType, inventoryId]), { groups: [], itemCount: 0, @@ -164,7 +165,7 @@ function InventoryRelatedGroupList() { ]} /> ); - + const isNotConstructedInventory = inventoryType !== 'constructed_inventory'; return ( <> , ] : []), - , + ...(isNotConstructedInventory + ? [ + , + ] + : []), ]} /> )} headerRow={ {t`Name`} - {t`Actions`} + {isNotConstructedInventory && {t`Actions`}} } renderRow={(group, index) => ( diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js index 0c6045b7dd..a8cba4aeac 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js @@ -1,6 +1,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; - +import { createMemoryHistory } from 'history'; +import { Route } from 'react-router-dom'; import { GroupsAPI, InventoriesAPI } from 'api'; import { mountWithContexts, @@ -13,14 +14,6 @@ jest.mock('../../../api/models/Groups'); jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/CredentialTypes'); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ - id: 1, - groupId: 2, - }), -})); - const mockGroups = [ { id: 1, @@ -65,6 +58,14 @@ const mockGroups = [ describe('', () => { let wrapper; + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 2, + groupId: 2, + inventoryType: 'inventory', + }), + })); beforeEach(async () => { GroupsAPI.readChildren.mockResolvedValue({ @@ -210,11 +211,22 @@ describe('', () => { GroupsAPI.readPotentialGroups.mockResolvedValue({ data: { count: mockGroups.length, results: mockGroups }, }); - await act(async () => { - wrapper = mountWithContexts(); + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/2/groups/2/nested_groups'], }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); - + await act(async () => { + wrapper = mountWithContexts( + + + , + { context: { router: { history } } } + ); + }); + await waitForElement( + wrapper, + 'InventoryRelatedGroupList', + (el) => el.length > 0 + ); act(() => wrapper.find('Button[aria-label="Add"]').prop('onClick')()); wrapper.update(); await act(async () => @@ -222,9 +234,9 @@ describe('', () => { .find('DropdownItem[aria-label="Add existing group"]') .prop('onClick')() ); - expect(GroupsAPI.readPotentialGroups).toBeCalledWith(2, { - not__id: 2, - not__parents: 2, + expect(GroupsAPI.readPotentialGroups).toBeCalledWith('2', { + not__id: '2', + not__parents: '2', order_by: 'name', page: 1, page_size: 5, @@ -261,3 +273,85 @@ describe('', () => { expect(wrapper.find('AdHocCommands')).toHaveLength(0); }); }); + +describe(' for constructed inventories', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + let wrapper; + + beforeEach(async () => { + GroupsAPI.readChildren.mockResolvedValue({ + data: { ...mockRelatedGroups }, + }); + InventoriesAPI.readGroupsOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [ + 'parents__search', + 'inventory__search', + 'inventory_sources__search', + 'created_by__search', + 'children__search', + 'modified_by__search', + 'hosts__search', + ], + }, + }); + InventoriesAPI.readAdHocOptions.mockResolvedValue({ + data: { + actions: { + GET: { + module_name: { + choices: [ + ['command', 'command'], + ['shell', 'shell'], + ], + }, + }, + POST: {}, + }, + }, + }); + const history = createMemoryHistory({ + initialEntries: [ + '/inventories/constructed_inventory/1/groups/2/nested_groupss', + ], + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { context: { router: { history } } } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Should not show associate, or disassociate button', async () => { + InventoriesAPI.readHostsOptions.mockResolvedValueOnce({ + data: { + actions: { + GET: {}, + }, + }, + }); + + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('AddDropDownButton').length).toBe(0); + expect(wrapper.find('DisassociateButton').length).toBe(0); + }); +}); diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.js index 3c2c9c090f..b30c872ba7 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.js @@ -1,6 +1,6 @@ import 'styled-components/macro'; import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { string, bool, func, number } from 'prop-types'; import { t } from '@lingui/macro'; @@ -21,7 +21,7 @@ function InventoryRelatedGroupListItem({ onSelect, }) { const labelId = `check-action-${group.id}`; - + const { inventoryType } = useParams(); return ( {group.name} - - - - - + + + + )} ); } diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.js index 4ab8fb17b1..eb3b6d99c2 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupListItem.test.js @@ -1,28 +1,43 @@ import React from 'react'; +import { createMemoryHistory } from 'history'; import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; import InventoryRelatedGroupListItem from './InventoryRelatedGroupListItem'; import mockRelatedGroups from '../shared/data.relatedGroups.json'; +import { Route } from 'react-router-dom'; jest.mock('../../../api'); +const mockGroup = mockRelatedGroups.results[0]; describe('', () => { let wrapper; - const mockGroup = mockRelatedGroups.results[0]; - + const history = createMemoryHistory({ + initialEntries: ['/inventories/inventory/1/groups/2/nested_groups'], + }); + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'inventory', + }), + })); beforeEach(() => { wrapper = mountWithContexts( - - - {}} - rowIndex={0} - /> - -
+ + + + {}} + rowIndex={0} + /> + +
+
, + { context: { router: { history } } } ); }); @@ -36,18 +51,60 @@ describe('', () => { test('edit button hidden from users without edit capabilities', () => { wrapper = mountWithContexts( - - - {}} - rowIndex={0} - /> - -
+ + + + {}} + rowIndex={0} + /> + +
+
, + { context: { router: { history } } } + ); + expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); + }); +}); + +describe(' for constructed inventories', () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 1, + groupId: 2, + inventoryType: 'constructed_inventory', + }), + })); + + let wrapper; + + test('edit button hidden from users without edit capabilities', () => { + const history = createMemoryHistory({ + initialEntries: [ + '/inventories/constructed_inventory/1/groups/2/nested_groups', + ], + }); + wrapper = mountWithContexts( + + + + {}} + rowIndex={0} + /> + +
+
, + { context: { router: { history } } } ); expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy(); }); diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js index d5904062b3..bca8ffc26a 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroups.js @@ -8,13 +8,13 @@ function InventoryRelatedGroups() { From e3d167dfd16b6f22e944ef3948f2c01080b6f313 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 22 Feb 2023 15:30:30 -0500 Subject: [PATCH 18/33] Hide constructed and smart inventories in Inventory Lookup --- awx/ui/src/components/HostForm/HostForm.js | 2 +- .../src/components/Lookup/InventoryLookup.js | 25 +++++++++++++------ .../components/Lookup/InventoryLookup.test.js | 4 +-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/awx/ui/src/components/HostForm/HostForm.js b/awx/ui/src/components/HostForm/HostForm.js index 8bbf6ac9f3..06e96c3f7f 100644 --- a/awx/ui/src/components/HostForm/HostForm.js +++ b/awx/ui/src/components/HostForm/HostForm.js @@ -38,7 +38,7 @@ const InventoryLookupField = ({ isDisabled }) => { error={inventoryMeta.error} validate={required(t`Select a value for this field`)} isDisabled={isDisabled} - hideSmartInventories + hideAdvancedInventories autoPopulate={!inventoryField.value?.id} /> ); diff --git a/awx/ui/src/components/Lookup/InventoryLookup.js b/awx/ui/src/components/Lookup/InventoryLookup.js index e37805451d..faf2e24a69 100644 --- a/awx/ui/src/components/Lookup/InventoryLookup.js +++ b/awx/ui/src/components/Lookup/InventoryLookup.js @@ -1,5 +1,5 @@ import React, { useCallback, useEffect } from 'react'; -import { func, bool, string } from 'prop-types'; +import { func, bool, string, oneOfType, arrayOf } from 'prop-types'; import { withRouter } from 'react-router-dom'; import { t } from '@lingui/macro'; import { InventoriesAPI } from 'api'; @@ -23,7 +23,7 @@ function InventoryLookup({ autoPopulate, fieldId, fieldName, - hideSmartInventories, + hideAdvancedInventories, history, isDisabled, isPromptableField, @@ -34,6 +34,7 @@ function InventoryLookup({ required, validate, value, + multiple, }) { const autoPopulateLookup = useAutoPopulateLookup(onChange); @@ -45,8 +46,8 @@ function InventoryLookup({ } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const inventoryKindParams = hideSmartInventories - ? { not__kind: 'smart' } + const inventoryKindParams = hideAdvancedInventories + ? { not__kind: ['smart', 'constructed'] } : {}; const [{ data }, actionsResponse] = await Promise.all([ InventoriesAPI.read( @@ -69,7 +70,10 @@ function InventoryLookup({ ).map((val) => val.slice(0, -8)), searchableKeys: Object.keys(actionsResponse.data.actions?.GET || {}) .filter((key) => { - if (['kind', 'host_filter'].includes(key) && hideSmartInventories) { + if ( + ['kind', 'host_filter'].includes(key) && + hideAdvancedInventories + ) { return false; } return actionsResponse.data.actions?.GET[key].filterable; @@ -187,6 +191,7 @@ function InventoryLookup({ onDebounce={checkInventoryName} fieldName={fieldName} validate={validate} + multiple={multiple} onBlur={onBlur} required={required} isLoading={isLoading} @@ -227,6 +232,10 @@ function InventoryLookup({ readOnly={!canDelete} selectItem={(item) => dispatch({ type: 'SELECT_ITEM', item })} deselectItem={(item) => dispatch({ type: 'DESELECT_ITEM', item })} + sortSelectedItems={(selectedItems) => + dispatch({ type: 'SET_SELECTED_ITEMS', selectedItems }) + } + isSelectedDraggable /> )} /> @@ -239,19 +248,19 @@ InventoryLookup.propTypes = { autoPopulate: bool, fieldId: string, fieldName: string, - hideSmartInventories: bool, + hideAdvancedInventories: bool, isDisabled: bool, onChange: func.isRequired, required: bool, validate: func, - value: Inventory, + value: oneOfType([Inventory, arrayOf(Inventory)]), }; InventoryLookup.defaultProps = { autoPopulate: false, fieldId: 'inventory', fieldName: 'inventory', - hideSmartInventories: false, + hideAdvancedInventories: false, isDisabled: false, required: false, validate: () => {}, diff --git a/awx/ui/src/components/Lookup/InventoryLookup.test.js b/awx/ui/src/components/Lookup/InventoryLookup.test.js index 120b4927e9..ad6f1aa709 100644 --- a/awx/ui/src/components/Lookup/InventoryLookup.test.js +++ b/awx/ui/src/components/Lookup/InventoryLookup.test.js @@ -70,14 +70,14 @@ describe('InventoryLookup', () => { await act(async () => { wrapper = mountWithContexts( - {}} hideSmartInventories /> + {}} hideAdvancedInventories /> ); }); wrapper.update(); expect(InventoriesAPI.read).toHaveBeenCalledTimes(1); expect(InventoriesAPI.read).toHaveBeenCalledWith({ - not__kind: 'smart', + not__kind: ['smart', 'constructed'], order_by: 'name', page: 1, page_size: 5, From d576e65858cdfbbb879931ead0afba7be439708c Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 23 Feb 2023 10:02:44 -0500 Subject: [PATCH 19/33] Add constructed inventory add form --- awx/ui/.eslintrc.json | 4 +- awx/ui/src/api/models/Inventories.js | 7 + .../components/CodeEditor/VariablesField.js | 35 ++- .../ConstructedInventoryAdd.js | 46 +++- .../ConstructedInventoryAdd.test.js | 115 ++++++++- .../InventoryRelatedGroupList.test.js | 2 +- .../shared/ConstructedInventoryForm.js | 229 ++++++++++++++++++ .../shared/ConstructedInventoryForm.test.js | 123 ++++++++++ .../shared/ConstructedInventoryHint.js | 164 +++++++++++++ .../shared/ConstructedInventoryHint.test.js | 46 ++++ .../Inventory/shared/Inventory.helptext.js | 38 +++ 11 files changed, 795 insertions(+), 14 deletions(-) create mode 100644 awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js create mode 100644 awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js create mode 100644 awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js create mode 100644 awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js diff --git a/awx/ui/.eslintrc.json b/awx/ui/.eslintrc.json index 7cf4965cbd..85eb903553 100644 --- a/awx/ui/.eslintrc.json +++ b/awx/ui/.eslintrc.json @@ -84,6 +84,7 @@ "displayKey", "sortedColumnKey", "maxHeight", + "maxWidth", "role", "aria-haspopup", "dropDirection", @@ -97,7 +98,8 @@ "data-cy", "fieldName", "splitButtonVariant", - "pageKey" + "pageKey", + "textId" ], "ignore": [ "Ansible", diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index 4fd145e178..8e586201b5 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -14,6 +14,7 @@ class Inventories extends InstanceGroupsMixin(Base) { this.readGroupsOptions = this.readGroupsOptions.bind(this); this.promoteGroup = this.promoteGroup.bind(this); this.readInputInventories = this.readInputInventories.bind(this); + this.associateInventory = this.associateInventory.bind(this); } readAccessList(id, params) { @@ -137,6 +138,12 @@ class Inventories extends InstanceGroupsMixin(Base) { disassociate: true, }); } + + associateInventory(id, inputInventoryId) { + return this.http.post(`${this.baseUrl}${id}/input_inventories/`, { + id: inputInventoryId, + }); + } } export default Inventories; diff --git a/awx/ui/src/components/CodeEditor/VariablesField.js b/awx/ui/src/components/CodeEditor/VariablesField.js index bbe8312c3d..eb48332d12 100644 --- a/awx/ui/src/components/CodeEditor/VariablesField.js +++ b/awx/ui/src/components/CodeEditor/VariablesField.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { string, bool, func, oneOf } from 'prop-types'; +import { string, bool, func, oneOf, shape } from 'prop-types'; import { t } from '@lingui/macro'; import { useField } from 'formik'; @@ -38,6 +38,8 @@ function VariablesField({ tooltip, initialMode, onModeChange, + isRequired, + validators, }) { // track focus manually, because the Code Editor library doesn't wire // into Formik completely @@ -48,13 +50,22 @@ function VariablesField({ return undefined; } try { - parseVariableField(value); + const parsedVariables = parseVariableField(value); + if (validators) { + const errorMessages = Object.keys(validators) + .map((field) => validators[field](parsedVariables[field])) + .filter((e) => e); + + if (errorMessages.length > 0) { + return errorMessages; + } + } } catch (error) { return error.message; } return undefined; }, - [shouldValidate] + [shouldValidate, validators] ); const [field, meta, helpers] = useField({ name, validate }); const [mode, setMode] = useState(() => @@ -120,6 +131,7 @@ function VariablesField({ setMode={handleModeChange} setShouldValidate={setShouldValidate} handleChange={handleChange} + isRequired={isRequired} /> {meta.error ? (
- {meta.error} + {(Array.isArray(meta.error) ? meta.error : [meta.error]).map( + (errorMessage) => ( +

{errorMessage}

+ ) + )}
) : null}
@@ -171,12 +187,16 @@ VariablesField.propTypes = { promptId: string, initialMode: oneOf([YAML_MODE, JSON_MODE]), onModeChange: func, + isRequired: bool, + validators: shape({}), }; VariablesField.defaultProps = { readOnly: false, promptId: null, initialMode: YAML_MODE, onModeChange: () => {}, + isRequired: false, + validators: {}, }; function VariablesFieldInternals({ @@ -192,6 +212,7 @@ function VariablesFieldInternals({ onExpand, setShouldValidate, handleChange, + isRequired, }) { const [field, meta, helpers] = useField(name); @@ -213,6 +234,12 @@ function VariablesFieldInternals({ {tooltip && } diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js index 1aaa2b7679..4263088d5f 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js @@ -1,14 +1,54 @@ -/* eslint i18next/no-literal-string: "off" */ -import React from 'react'; +import React, { useState } from 'react'; +import { useHistory } from 'react-router-dom'; import { Card, PageSection } from '@patternfly/react-core'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; import { CardBody } from 'components/Card'; +import ConstructedInventoryForm from '../shared/ConstructedInventoryForm'; function ConstructedInventoryAdd() { + const history = useHistory(); + const [submitError, setSubmitError] = useState(null); + + const handleCancel = () => { + history.push('/inventories'); + }; + + const handleSubmit = async (values) => { + try { + const { + data: { id: inventoryId }, + } = await ConstructedInventoriesAPI.create({ + ...values, + organization: values.organization?.id, + kind: 'constructed', + }); + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + for (const inputInventory of values.inputInventories) { + await InventoriesAPI.associateInventory(inventoryId, inputInventory.id); + } + for (const instanceGroup of values.instanceGroups) { + await InventoriesAPI.associateInstanceGroup( + inventoryId, + instanceGroup.id + ); + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + + history.push(`/inventories/constructed_inventory/${inventoryId}/details`); + } catch (error) { + setSubmitError(error); + } + }; + return ( -
Coming Soon!
+
diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js index 0a9a6eedd5..1272bf3b2e 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js @@ -1,15 +1,120 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { createMemoryHistory } from 'history'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import '@testing-library/jest-dom'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; import ConstructedInventoryAdd from './ConstructedInventoryAdd'; +jest.mock('api'); + describe('', () => { - test('initially renders successfully', async () => { - let wrapper; + let wrapper; + let history; + + const formData = { + name: 'Mock', + description: 'Foo', + organization: { id: 1 }, + kind: 'constructed', + source_vars: 'plugin: constructed', + inputInventories: [{ id: 2 }], + instanceGroups: [], + }; + + beforeEach(async () => { + ConstructedInventoriesAPI.readOptions.mockResolvedValue({ + data: { + related: {}, + actions: { + POST: { + limit: { + label: 'Limit', + help_text: '', + }, + update_cache_timeout: { + label: 'Update cache timeout', + help_text: 'help', + }, + verbosity: { + label: 'Verbosity', + help_text: '', + }, + }, + }, + }, + }); + history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/add'], + }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should navigate to inventories list on cancel', async () => { + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/add' + ); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual('/inventories'); + }); + + test('should navigate to constructed inventory detail after successful submission', async () => { + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + ConstructedInventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } }); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/add' + ); + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData); + }); + wrapper.update(); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/1/details' + ); + }); + + test('should make expected api requests on submit', async () => { + ConstructedInventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } }); + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData); + }); + expect(ConstructedInventoriesAPI.create).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInventory).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInventory).toHaveBeenCalledWith(1, 2); + expect(InventoriesAPI.associateInstanceGroup).not.toHaveBeenCalled(); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + ConstructedInventoriesAPI.create.mockImplementationOnce(() => + Promise.reject(error) + ); await act(async () => { wrapper = mountWithContexts(); }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryAdd').length).toBe(1); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js index a8cba4aeac..3dad28809f 100644 --- a/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js +++ b/awx/ui/src/screens/Inventory/InventoryRelatedGroups/InventoryRelatedGroupList.test.js @@ -323,7 +323,7 @@ describe(' for constructed inventories', () => { }); const history = createMemoryHistory({ initialEntries: [ - '/inventories/constructed_inventory/1/groups/2/nested_groupss', + '/inventories/constructed_inventory/1/groups/2/nested_groups', ], }); await act(async () => { diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js new file mode 100644 index 0000000000..6c1832c1d1 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js @@ -0,0 +1,229 @@ +import React, { useCallback, useEffect } from 'react'; +import { Formik, useField, useFormikContext } from 'formik'; +import { func, shape } from 'prop-types'; +import { t } from '@lingui/macro'; +import { ConstructedInventoriesAPI } from 'api'; +import { minMaxValue, required } from 'util/validators'; +import useRequest from 'hooks/useRequest'; +import { Form, FormGroup } from '@patternfly/react-core'; +import { VariablesField } from 'components/CodeEditor'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import FormActionGroup from 'components/FormActionGroup/FormActionGroup'; +import FormField, { FormSubmitError } from 'components/FormField'; +import { FormFullWidthLayout, FormColumnLayout } from 'components/FormLayout'; +import InstanceGroupsLookup from 'components/Lookup/InstanceGroupsLookup'; +import InventoryLookup from 'components/Lookup/InventoryLookup'; +import OrganizationLookup from 'components/Lookup/OrganizationLookup'; +import Popover from 'components/Popover'; +import { VerbositySelectField } from 'components/VerbositySelectField'; + +import ConstructedInventoryHint from './ConstructedInventoryHint'; +import getInventoryHelpTextStrings from './Inventory.helptext'; + +const constructedPluginValidator = { + plugin: required(t`The plugin parameter is required.`), +}; + +function ConstructedInventoryFormFields({ inventory = {}, options }) { + const helpText = getInventoryHelpTextStrings(); + const { setFieldValue, setFieldTouched } = useFormikContext(); + + const [instanceGroupsField, , instanceGroupsHelpers] = + useField('instanceGroups'); + const [organizationField, organizationMeta, organizationHelpers] = + useField('organization'); + const [inputInventoriesField, inputInventoriesMeta, inputInventoriesHelpers] = + useField({ + name: 'inputInventories', + validate: (value) => { + if (value.length === 0) { + return t`This field must not be blank`; + } + return undefined; + }, + }); + const handleOrganizationUpdate = useCallback( + (value) => { + setFieldValue('organization', value); + setFieldTouched('organization', true, false); + }, + [setFieldValue, setFieldTouched] + ); + const handleInputInventoriesUpdate = useCallback( + (value) => { + setFieldValue('inputInventories', value); + setFieldTouched('inputInventories', true, false); + }, + [setFieldValue, setFieldTouched] + ); + + return ( + <> + + + organizationHelpers.setTouched()} + onChange={handleOrganizationUpdate} + validate={required(t`Select a value for this field`)} + value={organizationField.value} + required + /> + { + instanceGroupsHelpers.setValue(value); + }} + tooltip={t`Select the Instance Groups for this Inventory to run on.`} + /> + + } + validated={ + !inputInventoriesMeta.touched || !inputInventoriesMeta.error + ? 'default' + : 'error' + } + > + inputInventoriesHelpers.setTouched()} + onChange={handleInputInventoriesUpdate} + touched={inputInventoriesMeta.touched} + value={inputInventoriesField.value} + hideAdvancedInventories + multiple + required + /> + + + + + + + + + + + + ); +} + +function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) { + const initialValues = { + description: '', + instanceGroups: [], + kind: 'constructed', + inputInventories: [], + limit: '', + name: '', + organization: null, + source_vars: '---', + update_cache_timeout: 0, + verbosity: 0, + }; + + const { + isLoading, + error, + request: fetchOptions, + result: options, + } = useRequest( + useCallback(async () => { + const res = await ConstructedInventoriesAPI.readOptions(); + const { data } = res; + return data.actions.POST; + }, []), + null + ); + + useEffect(() => { + fetchOptions(); + }, [fetchOptions]); + + if (isLoading || (!options && !error)) { + return ; + } + + if (error) { + return ; + } + + return ( + + {(formik) => ( +
+ + + + + +
+ )} +
+ ); +} + +ConstructedInventoryForm.propTypes = { + onCancel: func.isRequired, + onSubmit: func.isRequired, + submitError: shape({}), +}; + +ConstructedInventoryForm.defaultProps = { + submitError: null, +}; + +export default ConstructedInventoryForm; diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js new file mode 100644 index 0000000000..e3f50f1b93 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js @@ -0,0 +1,123 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { ConstructedInventoriesAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import ConstructedInventoryForm from './ConstructedInventoryForm'; + +jest.mock('../../../api'); + +const mockFormValues = { + kind: 'constructed', + name: 'new constructed inventory', + description: '', + organization: { id: 1, name: 'mock organization' }, + instanceGroups: [], + source_vars: 'plugin: constructed', + inputInventories: [{ id: 100, name: 'East' }], +}; + +describe('', () => { + let wrapper; + const onSubmit = jest.fn(); + + beforeEach(async () => { + ConstructedInventoriesAPI.readOptions.mockResolvedValue({ + data: { + related: {}, + actions: { + POST: { + limit: { + label: 'Limit', + help_text: '', + }, + update_cache_timeout: { + label: 'Update cache timeout', + help_text: 'help', + }, + verbosity: { + label: 'Verbosity', + help_text: '', + }, + }, + }, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + {}} onSubmit={onSubmit} /> + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should show expected form fields', () => { + expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Organization"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Instance Groups"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Input Inventories"]')).toHaveLength( + 1 + ); + expect( + wrapper.find('FormGroup[label="Cache timeout (seconds)"]') + ).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Verbosity"]')).toHaveLength(1); + expect(wrapper.find('FormGroup[label="Limit"]')).toHaveLength(1); + expect(wrapper.find('VariablesField[label="Source vars"]')).toHaveLength(1); + expect(wrapper.find('ConstructedInventoryHint')).toHaveLength(1); + expect(wrapper.find('Button[aria-label="Save"]')).toHaveLength(1); + expect(wrapper.find('Button[aria-label="Cancel"]')).toHaveLength(1); + }); + + test('should show field error when form is saved without a input inventories', async () => { + const inventoryErrorHelper = 'div#input-inventories-lookup-helper'; + expect(wrapper.find(inventoryErrorHelper).length).toBe(0); + wrapper.find('input#name').simulate('change', { + target: { value: mockFormValues.name, name: 'name' }, + }); + await act(async () => { + wrapper.find('button[aria-label="Save"]').simulate('click'); + }); + wrapper.update(); + expect(wrapper.find(inventoryErrorHelper).length).toBe(1); + expect(wrapper.find(inventoryErrorHelper).text()).toContain( + 'This field must not be blank' + ); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + test('should show field error when form is saved without constructed plugin parameter', async () => { + expect(wrapper.find('VariablesField .pf-m-error').length).toBe(0); + await act(async () => { + wrapper.find('VariablesField CodeEditor').invoke('onBlur')(''); + }); + wrapper.update(); + expect(wrapper.find('VariablesField .pf-m-error').length).toBe(1); + expect(wrapper.find('VariablesField .pf-m-error').text()).toBe( + 'The plugin parameter is required.' + ); + }); + + test('should throw content error when option request fails', async () => { + let newWrapper; + ConstructedInventoriesAPI.readOptions.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + newWrapper = mountWithContexts( + {}} onSubmit={() => {}} /> + ); + }); + expect(newWrapper.find('ContentError').length).toBe(0); + newWrapper.update(); + expect(newWrapper.find('ContentError').length).toBe(1); + jest.clearAllMocks(); + }); +}); diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js new file mode 100644 index 0000000000..34a3ca48f1 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js @@ -0,0 +1,164 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { + Alert, + AlertActionLink, + CodeBlock, + CodeBlockAction, + CodeBlockCode, + ClipboardCopyButton, +} from '@patternfly/react-core'; +import { + TableComposable, + Thead, + Tr, + Th, + Tbody, + Td, +} from '@patternfly/react-table'; +import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import getDocsBaseUrl from 'util/getDocsBaseUrl'; +import { useConfig } from 'contexts/Config'; + +function ConstructedInventoryHint() { + const config = useConfig(); + const [copied, setCopied] = React.useState(false); + + const clipboardCopyFunc = (event, text) => { + navigator.clipboard.writeText(text.toString()); + }; + + const onClick = (event, text) => { + clipboardCopyFunc(event, text); + setCopied(true); + }; + + const pluginSample = `plugin: constructed +strict: true +use_vars_plugins: true +groups: + shutdown: resolved_state == "shutdown" + shutdown_in_product_dev: resolved_state == "shutdown" and account_alias == "product_dev" +compose: + resolved_state: state | default("running")`; + + return ( + + {t`View constructed plugin documentation here`}{' '} + + + } + > + {t`WIP - More to come...`} +
+
+ + + + {t`Parameter`} + {t`Description`} + + + + + + plugin +

{t`string`}

+

{t`required`}

+ + + {t`Token that ensures this is a source file + for the ‘constructed’ plugin.`} + + + + + strict +

{t`boolean`}

+ + + {t`If yes make invalid entries a fatal error, otherwise skip and + continue.`}{' '} +
+ {t`If users need feedback about the correctness + of their constructed groups, it is highly recommended + to use strict: true in the plugin configuration.`} + + + + + use_vars_plugins +

{t`string`}

+ + + {t`Normally, for performance reasons, vars plugins get + executed after the inventory sources complete the + base inventory, this option allows for getting vars + related to hosts/groups from those plugins.`} + + + + + groups +

{t`dictionary`}

+ + + {t`Add hosts to group based on Jinja2 conditionals.`} + + + + + compose +

{t`dictionary`}

+ + + {t`Create vars from jinja2 expressions.`} + + + +
+
+
+ {t`Sample constructed inventory plugin:`} + + onClick(e, pluginSample)} + exitDelay={copied ? 1500 : 600} + maxWidth="110px" + variant="plain" + onTooltipHidden={() => setCopied(false)} + > + {copied + ? t`Successfully copied to clipboard!` + : t`Copy to clipboard`} + + + } + > + {pluginSample} + +
+ ); +} + +export default ConstructedInventoryHint; diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js new file mode 100644 index 0000000000..e4745773f4 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ConstructedInventoryHint from './ConstructedInventoryHint'; + +jest.mock('../../../api'); + +describe('', () => { + test('should render link to docs', () => { + render(); + expect( + screen.getByRole('link', { + name: 'View constructed plugin documentation here', + }) + ).toBeInTheDocument(); + }); + + test('should expand hint details', () => { + const { container } = render(); + + expect(container.querySelector('table')).not.toBeInTheDocument(); + expect(container.querySelector('code')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Info alert details' })); + expect(container.querySelector('table')).toBeInTheDocument(); + expect(container.querySelector('code')).toBeInTheDocument(); + }); + + test('should copy sample plugin code block', () => { + Object.assign(navigator, { + clipboard: { + writeText: () => {}, + }, + }); + jest.spyOn(navigator.clipboard, 'writeText'); + + const { container } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Info alert details' })); + fireEvent.click( + container.querySelector('button[aria-label="Copy to clipboard"]') + ); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('plugin: constructed') + ); + }); +}); diff --git a/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js b/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js index 158eaf62df..5345c115bb 100644 --- a/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js +++ b/awx/ui/src/screens/Inventory/shared/Inventory.helptext.js @@ -19,6 +19,8 @@ const ansibleDocUrls = { rhv: 'https://docs.ansible.com/ansible/latest/collections/ovirt/ovirt/ovirt_inventory.html', vmware: 'https://docs.ansible.com/ansible/latest/collections/community/vmware/vmware_vm_inventory_inventory.html', + constructed: + 'https://docs.ansible.com/ansible/latest/collections/ansible/builtin/constructed_inventory.html', }; const getInventoryHelpTextStrings = () => ({ @@ -189,6 +191,42 @@ const getInventoryHelpTextStrings = () => ({ ); }, + constructedInventorySourceVars: () => { + const yamlExample = ` + --- + plugin: constructed + strict: true + use_vars_plugins: true + `; + return ( + <> + + Variables used to configure the constructed inventory plugin. For a + detailed description of how to configure this plugin, see{' '} + + constructed inventory + {' '} + plugin configuration guide. + +
+
+
+
+ + Variables must be in JSON or YAML syntax. Use the radio button to + toggle between the two. + +
+
+ YAML: +
{yamlExample}
+ + ); + }, sourcePath: t`The inventory file to be synced by this source. You can select from the dropdown or enter a file within the input.`, From 2bffddb5fb06ed74e483a911ed0ce096a0d9f1ca Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 23 Feb 2023 19:16:12 -0500 Subject: [PATCH 20/33] Add constructed inventory edit form --- awx/ui/src/api/models/Inventories.js | 8 + .../screens/Inventory/ConstructedInventory.js | 13 +- .../ConstructedInventoryAdd.test.js | 1 + .../ConstructedInventoryEdit.js | 119 ++++++++++- .../ConstructedInventoryEdit.test.js | 195 +++++++++++++++++- .../InventoryList/InventoryListItem.js | 2 +- .../shared/ConstructedInventoryForm.js | 29 ++- awx/ui/src/screens/Inventory/shared/utils.js | 1 + 8 files changed, 337 insertions(+), 31 deletions(-) diff --git a/awx/ui/src/api/models/Inventories.js b/awx/ui/src/api/models/Inventories.js index 8e586201b5..7e53e161d1 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -15,6 +15,7 @@ class Inventories extends InstanceGroupsMixin(Base) { this.promoteGroup = this.promoteGroup.bind(this); this.readInputInventories = this.readInputInventories.bind(this); this.associateInventory = this.associateInventory.bind(this); + this.disassociateInventory = this.disassociateInventory.bind(this); } readAccessList(id, params) { @@ -144,6 +145,13 @@ class Inventories extends InstanceGroupsMixin(Base) { id: inputInventoryId, }); } + + disassociateInventory(id, inputInventoryId) { + return this.http.post(`${this.baseUrl}${id}/input_inventories/`, { + id: inputInventoryId, + disassociate: true, + }); + } } export default Inventories; diff --git a/awx/ui/src/screens/Inventory/ConstructedInventory.js b/awx/ui/src/screens/Inventory/ConstructedInventory.js index 086c755adb..ff02136101 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventory.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.js @@ -33,8 +33,8 @@ function ConstructedInventory({ setBreadcrumb }) { const { result: inventory, error: contentError, - isLoading: hasContentLoading, request: fetchInventory, + isLoading, } = useRequest( useCallback(async () => { const { data } = await ConstructedInventoriesAPI.readDetail( @@ -42,7 +42,7 @@ function ConstructedInventory({ setBreadcrumb }) { ); return data; }, [match.params.id]), - { isLoading: true } + { inventory: null, isLoading: true } ); useEffect(() => { @@ -78,7 +78,7 @@ function ConstructedInventory({ setBreadcrumb }) { { name: t`Job Templates`, link: `${match.url}/job_templates`, id: 5 }, ]; - if (hasContentLoading) { + if (isLoading) { return ( @@ -133,16 +133,13 @@ function ConstructedInventory({ setBreadcrumb }) { path="/inventories/constructed_inventory/:id/details" key="details" > - +
, - + , ', () => { await act(async () => { wrapper = mountWithContexts(); }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); expect(wrapper.find('FormSubmitError').length).toBe(0); await act(async () => { wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js index a49e7eaaed..bb87534379 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js @@ -1,11 +1,122 @@ -/* eslint i18next/no-literal-string: "off" */ -import React from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; +import useRequest from 'hooks/useRequest'; import { CardBody } from 'components/Card'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +import ConstructedInventoryForm from '../shared/ConstructedInventoryForm'; + +function isEqual(array1, array2) { + return ( + array1.length === array2.length && + array1.every((element, index) => element.id === array2[index].id) + ); +} + +function ConstructedInventoryEdit({ inventory }) { + const history = useHistory(); + const [submitError, setSubmitError] = useState(null); + const detailsUrl = `/inventories/constructed_inventory/${inventory.id}/details`; + const constructedInventoryId = inventory.id; + + const { + result: { initialInstanceGroups, initialInputInventories }, + request: fetchedRelatedData, + error: contentError, + isLoading, + } = useRequest( + useCallback(async () => { + const [instanceGroupsResponse, inputInventoriesResponse] = + await Promise.all([ + InventoriesAPI.readInstanceGroups(constructedInventoryId), + InventoriesAPI.readInputInventories(constructedInventoryId), + ]); + + return { + initialInstanceGroups: instanceGroupsResponse.data.results, + initialInputInventories: inputInventoriesResponse.data.results, + }; + }, [constructedInventoryId]), + { + initialInstanceGroups: [], + initialInputInventories: [], + isLoading: true, + } + ); + useEffect(() => { + fetchedRelatedData(); + }, [fetchedRelatedData]); + + const handleSubmit = async (values) => { + const { + instanceGroups, + inputInventories, + organization, + ...remainingValues + } = values; + + remainingValues.organization = organization.id; + remainingValues.kind = 'constructed'; + + try { + await Promise.all([ + ConstructedInventoriesAPI.update( + constructedInventoryId, + remainingValues + ), + InventoriesAPI.orderInstanceGroups( + constructedInventoryId, + instanceGroups, + initialInstanceGroups + ), + ]); + /* eslint-disable no-await-in-loop, no-restricted-syntax */ + // Resolve Promises sequentially to avoid race condition + if (!isEqual(initialInputInventories, values.inputInventories)) { + for (const inputInventory of initialInputInventories) { + await InventoriesAPI.disassociateInventory( + constructedInventoryId, + inputInventory.id + ); + } + for (const inputInventory of values.inputInventories) { + await InventoriesAPI.associateInventory( + constructedInventoryId, + inputInventory.id + ); + } + } + /* eslint-enable no-await-in-loop, no-restricted-syntax */ + + history.push( + `/inventories/constructed_inventory/${constructedInventoryId}/details` + ); + } catch (error) { + setSubmitError(error); + } + }; + + const handleCancel = () => history.push(detailsUrl); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } -function ConstructedInventoryEdit() { return ( -
Coming Soon!
+
); } diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js index 02b0747880..ee52a8ca1b 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js @@ -1,15 +1,196 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; + import ConstructedInventoryEdit from './ConstructedInventoryEdit'; +jest.mock('api'); describe('', () => { - test('initially renders successfully', async () => { - let wrapper; - await act(async () => { - wrapper = mountWithContexts(); + let wrapper; + let history; + + const mockInv = { + name: 'Mock', + id: 7, + description: 'Foo', + organization: { id: 1 }, + kind: 'constructed', + source_vars: 'plugin: constructed', + limit: 'product_dev', + }; + const associatedInstanceGroups = [ + { + id: 1, + name: 'Foo', + }, + ]; + const associatedInputInventories = [ + { + id: 123, + name: 'input_inventory_123', + }, + { + id: 456, + name: 'input_inventory_456', + }, + ]; + const mockFormValues = { + kind: 'constructed', + name: 'new constructed inventory', + description: '', + organization: { id: 1, name: 'mock organization' }, + instanceGroups: associatedInstanceGroups, + source_vars: 'plugin: constructed', + inputInventories: associatedInputInventories, + }; + + beforeEach(async () => { + ConstructedInventoriesAPI.readOptions.mockResolvedValue({ + data: { + related: {}, + actions: { + POST: { + limit: { + label: 'Limit', + help_text: '', + }, + update_cache_timeout: { + label: 'Update cache timeout', + help_text: 'help', + }, + verbosity: { + label: 'Verbosity', + help_text: '', + }, + }, + }, + }, }); - expect(wrapper.length).toBe(1); - expect(wrapper.find('ConstructedInventoryEdit').length).toBe(1); + InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { + results: associatedInstanceGroups, + }, + }); + InventoriesAPI.readInputInventories.mockResolvedValue({ + data: { + results: [ + { + id: 456, + name: 'input_inventory_456', + }, + ], + }, + }); + history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/7/edit'], + }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should navigate to inventories details on cancel', async () => { + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/edit' + ); + await act(async () => { + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + }); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/details' + ); + }); + + test('should navigate to constructed inventory detail after successful submission', async () => { + ConstructedInventoriesAPI.update.mockResolvedValueOnce({ data: { id: 1 } }); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/edit' + ); + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')( + mockFormValues + ); + }); + wrapper.update(); + expect(history.location.pathname).toEqual( + '/inventories/constructed_inventory/7/details' + ); + }); + + test('should make expected api requests on submit', async () => { + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')( + mockFormValues + ); + }); + expect(ConstructedInventoriesAPI.update).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInstanceGroup).not.toHaveBeenCalled(); + expect(InventoriesAPI.disassociateInventory).toHaveBeenCalledTimes(1); + expect(InventoriesAPI.associateInventory).toHaveBeenCalledTimes(2); + expect(InventoriesAPI.associateInventory).toHaveBeenNthCalledWith( + 1, + 7, + 123 + ); + expect(InventoriesAPI.associateInventory).toHaveBeenNthCalledWith( + 2, + 7, + 456 + ); + }); + + test('should throw content error', async () => { + expect(wrapper.find('ContentError').length).toBe(0); + InventoriesAPI.readInstanceGroups.mockImplementationOnce(() => + Promise.reject(new Error()) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('ContentError').length).toBe(1); + }); + + test('unsuccessful form submission should show an error message', async () => { + const error = { + response: { + data: { detail: 'An error occurred' }, + }, + }; + ConstructedInventoriesAPI.update.mockImplementationOnce(() => + Promise.reject(error) + ); + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + expect(wrapper.find('FormSubmitError').length).toBe(0); + await act(async () => { + wrapper.find('ConstructedInventoryForm').invoke('onSubmit')( + mockFormValues + ); + }); + wrapper.update(); + expect(wrapper.find('FormSubmitError').length).toBe(1); }); }); diff --git a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js index 3828401045..b0a8bdfc38 100644 --- a/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js +++ b/awx/ui/src/screens/Inventory/InventoryList/InventoryListItem.js @@ -146,7 +146,7 @@ function InventoryListItem({ aria-label={t`Edit Inventory`} variant="plain" component={Link} - to={`${getInventoryPath(inventory)}edit`} + to={`${getInventoryPath(inventory)}/edit`} > diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js index 6c1832c1d1..470ab61366 100644 --- a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js @@ -158,18 +158,25 @@ function ConstructedInventoryFormFields({ inventory = {}, options }) { ); } -function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) { +function ConstructedInventoryForm({ + constructedInventory, + instanceGroups, + inputInventories, + onCancel, + onSubmit, + submitError, +}) { const initialValues = { - description: '', - instanceGroups: [], kind: 'constructed', - inputInventories: [], - limit: '', - name: '', - organization: null, - source_vars: '---', - update_cache_timeout: 0, - verbosity: 0, + description: constructedInventory?.description || '', + instanceGroups: instanceGroups || [], + inputInventories: inputInventories || [], + limit: constructedInventory?.limit || '', + name: constructedInventory?.name || '', + organization: constructedInventory?.summary_fields?.organization || null, + update_cache_timeout: constructedInventory?.update_cache_timeout || 0, + verbosity: constructedInventory?.verbosity || 0, + source_vars: constructedInventory?.source_vars || '---', }; const { @@ -204,7 +211,7 @@ function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) {
- + {submitError && } { export default parseHostFilter; export function getInventoryPath(inventory) { + if (!inventory) return '/inventories'; const url = { '': `/inventories/inventory/${inventory.id}`, smart: `/inventories/smart_inventory/${inventory.id}`, From ab0463bf2a8bbdd456b14d76979d23701ce1967f Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Fri, 3 Mar 2023 08:28:56 -0800 Subject: [PATCH 21/33] Ordered m2m for Inventory/Inventory relationship (#13602) Including changes to our custom Ordered m2m field which previously broke if the source and target model was the same. Signed-off-by: Rick Elrod Co-authored-by: Alan Rominger --- awx/main/fields.py | 23 ++++++---- .../migrations/0182_constructed_inventory.py | 18 +++++++- awx/main/models/__init__.py | 1 + awx/main/models/inventory.py | 14 ++++++- .../test_instance_group_ordering.py | 42 ++++++++++++++++++- awx/main/tests/functional/test_instances.py | 3 +- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 5d3710ed51..ce548fb58c 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -954,6 +954,16 @@ class OrderedManyToManyDescriptor(ManyToManyDescriptor): def get_queryset(self): return super(OrderedManyRelatedManager, self).get_queryset().order_by('%s__position' % self.through._meta.model_name) + def add(self, *objects): + if len(objects) > 1: + raise RuntimeError('Ordered many-to-many fields do not support multiple objects') + return super().add(*objects) + + def remove(self, *objects): + if len(objects) > 1: + raise RuntimeError('Ordered many-to-many fields do not support multiple objects') + return super().remove(*objects) + return OrderedManyRelatedManager return add_custom_queryset_to_many_related_manager( @@ -971,13 +981,12 @@ class OrderedManyToManyField(models.ManyToManyField): by a special `position` column on the M2M table """ - def _update_m2m_position(self, sender, **kwargs): - if kwargs.get('action') in ('post_add', 'post_remove'): - order_with_respect_to = None - for field in sender._meta.local_fields: - if isinstance(field, models.ForeignKey) and isinstance(kwargs['instance'], field.related_model): - order_with_respect_to = field.name - for i, ig in enumerate(sender.objects.filter(**{order_with_respect_to: kwargs['instance'].pk})): + def _update_m2m_position(self, sender, instance, action, **kwargs): + if action in ('post_add', 'post_remove'): + descriptor = getattr(instance, self.name) + order_with_respect_to = descriptor.source_field_name + + for i, ig in enumerate(sender.objects.filter(**{order_with_respect_to: instance.pk})): if ig.position != i: ig.position = i ig.save() diff --git a/awx/main/migrations/0182_constructed_inventory.py b/awx/main/migrations/0182_constructed_inventory.py index a41e303597..10ece7b2f4 100644 --- a/awx/main/migrations/0182_constructed_inventory.py +++ b/awx/main/migrations/0182_constructed_inventory.py @@ -1,6 +1,8 @@ # Generated by Django 3.2.16 on 2022-12-07 14:20 +import awx.main.fields from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -9,13 +11,27 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='InventoryConstructedInventoryMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position', models.PositiveIntegerField(db_index=True, default=None, null=True)), + ( + 'constructed_inventory', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.inventory', related_name='constructed_inventory_memberships'), + ), + ('input_inventory', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.inventory')), + ], + ), migrations.AddField( model_name='inventory', name='input_inventories', - field=models.ManyToManyField( + field=awx.main.fields.OrderedManyToManyField( blank=True, + through_fields=('constructed_inventory', 'input_inventory'), help_text='Only valid for constructed inventories, this links to the inventories that will be used.', related_name='destination_inventories', + through='main.InventoryConstructedInventoryMembership', to='main.Inventory', ), ), diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 8a608aeead..19a422740c 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -18,6 +18,7 @@ from awx.main.models.inventory import ( # noqa HostMetric, HostMetricSummaryMonthly, Inventory, + InventoryConstructedInventoryMembership, InventorySource, InventoryUpdate, SmartInventoryMembership, diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 75609b91b6..978e520278 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -58,6 +58,16 @@ __all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', ' logger = logging.getLogger('awx.main.models.inventory') +class InventoryConstructedInventoryMembership(models.Model): + constructed_inventory = models.ForeignKey('Inventory', on_delete=models.CASCADE, related_name='constructed_inventory_memberships') + input_inventory = models.ForeignKey('Inventory', on_delete=models.CASCADE) + position = models.PositiveIntegerField( + null=True, + default=None, + db_index=True, + ) + + class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): """ an inventory source contains lists and hosts. @@ -140,11 +150,13 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): default=None, help_text=_('Filter that will be applied to the hosts of this inventory.'), ) - input_inventories = models.ManyToManyField( + input_inventories = OrderedManyToManyField( 'Inventory', blank=True, + through_fields=('constructed_inventory', 'input_inventory'), related_name='destination_inventories', help_text=_('Only valid for constructed inventories, this links to the inventories that will be used.'), + through='InventoryConstructedInventoryMembership', ) instance_groups = OrderedManyToManyField( 'InstanceGroup', diff --git a/awx/main/tests/functional/test_instance_group_ordering.py b/awx/main/tests/functional/test_instance_group_ordering.py index 42c69ffc7f..fb8c0db168 100644 --- a/awx/main/tests/functional/test_instance_group_ordering.py +++ b/awx/main/tests/functional/test_instance_group_ordering.py @@ -1,6 +1,6 @@ import pytest -from awx.main.models import InstanceGroup +from awx.main.models import InstanceGroup, Inventory @pytest.fixture(scope='function') @@ -38,6 +38,16 @@ def test_instance_group_ordering(source_model): assert source_model.instance_groups.through.objects.count() == 0 +@pytest.mark.django_db +@pytest.mark.parametrize('source_model', ['job_template', 'inventory', 'organization'], indirect=True) +def test_instance_group_bulk_add(source_model): + groups = [InstanceGroup.objects.create(name='host-%d' % i) for i in range(5)] + groups.reverse() + with pytest.raises(RuntimeError) as err: + source_model.instance_groups.add(*groups) + assert 'Ordered many-to-many fields do not support multiple objects' in str(err) + + @pytest.mark.django_db @pytest.mark.parametrize('source_model', ['job_template', 'inventory', 'organization'], indirect=True) def test_instance_group_middle_deletion(source_model): @@ -66,3 +76,33 @@ def test_explicit_ordering(source_model): assert [g.name for g in source_model.instance_groups.all()] == ['host-4', 'host-3', 'host-2', 'host-1', 'host-0'] assert [g.name for g in source_model.instance_groups.order_by('name').all()] == ['host-0', 'host-1', 'host-2', 'host-3', 'host-4'] + + +@pytest.mark.django_db +def test_input_inventories_ordering(): + constructed_inventory = Inventory.objects.create(name='my_constructed', kind='constructed') + input_inventories = [Inventory.objects.create(name='inv-%d' % i) for i in range(5)] + input_inventories.reverse() + for inv in input_inventories: + constructed_inventory.input_inventories.add(inv) + + assert [g.name for g in constructed_inventory.input_inventories.all()] == ['inv-4', 'inv-3', 'inv-2', 'inv-1', 'inv-0'] + assert [(row.position, row.input_inventory.name) for row in constructed_inventory.input_inventories.through.objects.all()] == [ + (0, 'inv-4'), + (1, 'inv-3'), + (2, 'inv-2'), + (3, 'inv-1'), + (4, 'inv-0'), + ] + + constructed_inventory.input_inventories.remove(input_inventories[0]) + assert [g.name for g in constructed_inventory.input_inventories.all()] == ['inv-3', 'inv-2', 'inv-1', 'inv-0'] + assert [(row.position, row.input_inventory.name) for row in constructed_inventory.input_inventories.through.objects.all()] == [ + (0, 'inv-3'), + (1, 'inv-2'), + (2, 'inv-1'), + (3, 'inv-0'), + ] + + constructed_inventory.input_inventories.clear() + assert constructed_inventory.input_inventories.through.objects.count() == 0 diff --git a/awx/main/tests/functional/test_instances.py b/awx/main/tests/functional/test_instances.py index 9db370225c..4ed652179d 100644 --- a/awx/main/tests/functional/test_instances.py +++ b/awx/main/tests/functional/test_instances.py @@ -94,7 +94,8 @@ def test_instance_dup(org_admin, organization, project, instance_factory, instan ig_all = instance_group_factory("all", instances=[i1, i2, i3]) ig_dup = instance_group_factory("duplicates", instances=[i1]) - project.organization.instance_groups.add(ig_all, ig_dup) + project.organization.instance_groups.add(ig_all) + project.organization.instance_groups.add(ig_dup) actual_num_instances = Instance.objects.count() list_response = get(reverse('api:instance_list'), user=system_auditor) api_num_instances_auditor = list(list_response.data.items())[0][1] From 054a70bda48f18cc55f9e2d2d6201aae7f8a93a2 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 28 Feb 2023 16:03:35 -0500 Subject: [PATCH 22/33] Filter constructed inventory hosts from smart inventory host lookup --- awx/ui/src/components/Lookup/HostFilterLookup.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui/src/components/Lookup/HostFilterLookup.js b/awx/ui/src/components/Lookup/HostFilterLookup.js index 46e0a3dd1f..ce02a9802b 100644 --- a/awx/ui/src/components/Lookup/HostFilterLookup.js +++ b/awx/ui/src/components/Lookup/HostFilterLookup.js @@ -84,6 +84,7 @@ const QS_CONFIG = getQSConfig( page: 1, page_size: 5, order_by: 'name', + not__inventory__kind: 'constructed', }, ['id', 'page', 'page_size', 'inventory'] ); From ce4c1c11b3e818ca17d7cf4db2bad6ba08becf17 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 14 Mar 2023 15:26:00 -0400 Subject: [PATCH 23/33] Remove towervars from constructed inventory hosts (#13686) --- awx/main/management/commands/inventory_import.py | 8 +++++++- awx/main/models/inventory.py | 13 ++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 6fa5b59346..8150936054 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -654,13 +654,19 @@ class Command(BaseCommand): mem_host = self.all_group.all_hosts[mem_host_name] import_vars = mem_host.variables host_desc = import_vars.pop('_awx_description', 'imported') - host_attrs = dict(variables=json.dumps(import_vars), description=host_desc) + host_attrs = dict(description=host_desc) enabled = self._get_enabled(mem_host.variables) if enabled is not None: host_attrs['enabled'] = enabled if self.instance_id_var: instance_id = self._get_instance_id(mem_host.variables) host_attrs['instance_id'] = instance_id + if self.inventory.kind == 'constructed': + # remote towervars so the constructed hosts do not have extra variables + for prefix in ('host', 'tower'): + for var in ('remote_{}_enabled', 'remote_{}_id'): + import_vars.pop(var.format(prefix), None) + host_attrs['variables'] = json.dumps(import_vars) try: sanitize_jinja(mem_host_name) except ValueError as e: diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 978e520278..010cfaa342 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -357,13 +357,12 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): for host in hosts: data['_meta']['hostvars'][host.name] = host.variables_dict if towervars: - tower_dict = dict( - remote_tower_enabled=str(host.enabled).lower(), - remote_tower_id=host.id, - remote_host_enabled=str(host.enabled).lower(), - remote_host_id=host.id, - ) - data['_meta']['hostvars'][host.name].update(tower_dict) + for prefix in ('host', 'tower'): + tower_dict = { + f'remote_{prefix}_enabled': str(host.enabled).lower(), + f'remote_{prefix}_id': host.id, + } + data['_meta']['hostvars'][host.name].update(tower_dict) return data From 771b831da81b08d5348c59c56e0acec14f57cb7b Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 14 Mar 2023 14:17:45 -0400 Subject: [PATCH 24/33] Fail constructed inventory if ANY source is unparsed --- awx/main/models/inventory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 010cfaa342..c8f609b2ca 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1643,8 +1643,9 @@ class constructed(PluginFileInjector): def build_env(self, *args, **kwargs): env = super().build_env(*args, **kwargs) - # Enable all types of inventory plugins so we pick up the script files from source inventories - del env['ANSIBLE_INVENTORY_ENABLED'] + # Enable script inventory plugin so we pick up the script files from source inventories + env['ANSIBLE_INVENTORY_ENABLED'] += ',script' + env['ANSIBLE_INVENTORY_ANY_UNPARSED_IS_FAILED'] = 'True' return env From aa631a1ba7a6451830c9f4fd55c2d17248584146 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 15 Mar 2023 10:35:12 -0400 Subject: [PATCH 25/33] [constructed-inventory] Allow filtering based on facts (#13678) * initial functional filter-on-facts functionality * Move facts to its own module to make interface more coherent * Update test --- awx/main/models/jobs.py | 115 +-------------------- awx/main/tasks/facts.py | 126 ++++++++++++++++++++++++ awx/main/tasks/jobs.py | 37 +++++-- awx/main/tests/unit/models/test_jobs.py | 32 +++--- 4 files changed, 176 insertions(+), 134 deletions(-) create mode 100644 awx/main/tasks/facts.py diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 61b87f4807..ae9e66ae5f 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -2,12 +2,8 @@ # All Rights Reserved. # Python -import codecs -import datetime import logging -import os import time -import json from urllib.parse import urljoin @@ -15,11 +11,8 @@ from urllib.parse import urljoin from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from django.db.models.query import QuerySet # from django.core.cache import cache -from django.utils.encoding import smart_str -from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.core.exceptions import FieldDoesNotExist @@ -44,7 +37,7 @@ from awx.main.models.notifications import ( NotificationTemplate, JobNotificationMixin, ) -from awx.main.utils import parse_yaml_or_json, getattr_dne, NullablePromptPseudoField, polymorphic, log_excess_runtime +from awx.main.utils import parse_yaml_or_json, getattr_dne, NullablePromptPseudoField, polymorphic from awx.main.fields import ImplicitRoleField, AskForField, JSONBlob, OrderedManyToManyField from awx.main.models.mixins import ( ResourceMixin, @@ -60,8 +53,6 @@ from awx.main.constants import JOB_VARIABLE_PREFIXES logger = logging.getLogger('awx.main.models.jobs') -analytics_logger = logging.getLogger('awx.analytics.job_events') -system_tracking_logger = logging.getLogger('awx.analytics.system_tracking') __all__ = ['JobTemplate', 'JobLaunchConfig', 'Job', 'JobHostSummary', 'SystemJobTemplate', 'SystemJob'] @@ -848,110 +839,6 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana def get_notification_friendly_name(self): return "Job" - def _get_inventory_hosts(self, only=('name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id'), **filters): - """Return value is an iterable for the relevant hosts for this job""" - if not self.inventory: - return [] - host_queryset = self.inventory.hosts.only(*only) - if filters: - host_queryset = host_queryset.filter(**filters) - host_queryset = self.inventory.get_sliced_hosts(host_queryset, self.job_slice_number, self.job_slice_count) - if isinstance(host_queryset, QuerySet): - return host_queryset.iterator() - return host_queryset - - @log_excess_runtime(logger, debug_cutoff=0.01, msg='Job {job_id} host facts prepared for {written_ct} hosts, took {delta:.3f} s', add_log_data=True) - def start_job_fact_cache(self, destination, log_data, timeout=None): - self.log_lifecycle("start_job_fact_cache") - log_data['job_id'] = self.id - log_data['written_ct'] = 0 - os.makedirs(destination, mode=0o700) - - if timeout is None: - timeout = settings.ANSIBLE_FACT_CACHE_TIMEOUT - if timeout > 0: - # exclude hosts with fact data older than `settings.ANSIBLE_FACT_CACHE_TIMEOUT seconds` - timeout = now() - datetime.timedelta(seconds=timeout) - hosts = self._get_inventory_hosts(ansible_facts_modified__gte=timeout) - else: - hosts = self._get_inventory_hosts() - - last_filepath_written = None - for host in hosts: - filepath = os.sep.join(map(str, [destination, host.name])) - if not os.path.realpath(filepath).startswith(destination): - system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) - continue - try: - with codecs.open(filepath, 'w', encoding='utf-8') as f: - os.chmod(f.name, 0o600) - json.dump(host.ansible_facts, f) - log_data['written_ct'] += 1 - last_filepath_written = filepath - except IOError: - system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) - continue - # make note of the time we wrote the last file so we can check if any file changed later - if last_filepath_written: - return os.path.getmtime(last_filepath_written) - return None - - @log_excess_runtime( - logger, - debug_cutoff=0.01, - msg='Job {job_id} host facts: updated {updated_ct}, cleared {cleared_ct}, unchanged {unmodified_ct}, took {delta:.3f} s', - add_log_data=True, - ) - def finish_job_fact_cache(self, destination, facts_write_time, log_data): - self.log_lifecycle("finish_job_fact_cache") - log_data['job_id'] = self.id - log_data['updated_ct'] = 0 - log_data['unmodified_ct'] = 0 - log_data['cleared_ct'] = 0 - hosts_to_update = [] - for host in self._get_inventory_hosts(): - filepath = os.sep.join(map(str, [destination, host.name])) - if not os.path.realpath(filepath).startswith(destination): - system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) - continue - if os.path.exists(filepath): - # If the file changed since we wrote the last facts file, pre-playbook run... - modified = os.path.getmtime(filepath) - if (not facts_write_time) or modified > facts_write_time: - with codecs.open(filepath, 'r', encoding='utf-8') as f: - try: - ansible_facts = json.load(f) - except ValueError: - continue - host.ansible_facts = ansible_facts - host.ansible_facts_modified = now() - hosts_to_update.append(host) - system_tracking_logger.info( - 'New fact for inventory {} host {}'.format(smart_str(host.inventory.name), smart_str(host.name)), - extra=dict( - inventory_id=host.inventory.id, - host_name=host.name, - ansible_facts=host.ansible_facts, - ansible_facts_modified=host.ansible_facts_modified.isoformat(), - job_id=self.id, - ), - ) - log_data['updated_ct'] += 1 - else: - log_data['unmodified_ct'] += 1 - else: - # if the file goes missing, ansible removed it (likely via clear_facts) - host.ansible_facts = {} - host.ansible_facts_modified = now() - hosts_to_update.append(host) - system_tracking_logger.info('Facts cleared for inventory {} host {}'.format(smart_str(host.inventory.name), smart_str(host.name))) - log_data['cleared_ct'] += 1 - if len(hosts_to_update) > 100: - self.inventory.hosts.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) - hosts_to_update = [] - if hosts_to_update: - self.inventory.hosts.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) - class LaunchTimeConfigBase(BaseModel): """ diff --git a/awx/main/tasks/facts.py b/awx/main/tasks/facts.py new file mode 100644 index 0000000000..ba48bc2249 --- /dev/null +++ b/awx/main/tasks/facts.py @@ -0,0 +1,126 @@ +import codecs +import datetime +import os +import json +import logging + +# Django +from django.conf import settings +from django.db.models.query import QuerySet +from django.utils.encoding import smart_str +from django.utils.timezone import now + +# AWX +from awx.main.utils.common import log_excess_runtime + + +logger = logging.getLogger('awx.main.tasks.facts') +system_tracking_logger = logging.getLogger('awx.analytics.system_tracking') + + +def _get_inventory_hosts(inventory, slice_number, slice_count, only=('name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id'), **filters): + """Return value is an iterable for the relevant hosts for this job""" + if not inventory: + return [] + host_queryset = inventory.hosts.only(*only) + if filters: + host_queryset = host_queryset.filter(**filters) + host_queryset = inventory.get_sliced_hosts(host_queryset, slice_number, slice_count) + if isinstance(host_queryset, QuerySet): + return host_queryset.iterator() + return host_queryset + + +@log_excess_runtime(logger, debug_cutoff=0.01, msg='Inventory {inventory_id} host facts prepared for {written_ct} hosts, took {delta:.3f} s', add_log_data=True) +def start_fact_cache(inventory, destination, log_data, timeout=None, slice_number=0, slice_count=1): + log_data['inventory_id'] = inventory.id + log_data['written_ct'] = 0 + try: + os.makedirs(destination, mode=0o700) + except FileExistsError: + pass + + if timeout is None: + timeout = settings.ANSIBLE_FACT_CACHE_TIMEOUT + if timeout > 0: + # exclude hosts with fact data older than `settings.ANSIBLE_FACT_CACHE_TIMEOUT seconds` + timeout = now() - datetime.timedelta(seconds=timeout) + hosts = _get_inventory_hosts(inventory, slice_number, slice_count, ansible_facts_modified__gte=timeout) + else: + hosts = _get_inventory_hosts(inventory, slice_number, slice_count) + + last_filepath_written = None + for host in hosts: + filepath = os.sep.join(map(str, [destination, host.name])) + if not os.path.realpath(filepath).startswith(destination): + system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) + continue + try: + with codecs.open(filepath, 'w', encoding='utf-8') as f: + os.chmod(f.name, 0o600) + json.dump(host.ansible_facts, f) + log_data['written_ct'] += 1 + last_filepath_written = filepath + except IOError: + system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) + continue + # make note of the time we wrote the last file so we can check if any file changed later + if last_filepath_written: + return os.path.getmtime(last_filepath_written) + return None + + +@log_excess_runtime( + logger, + debug_cutoff=0.01, + msg='Inventory {inventory_id} host facts: updated {updated_ct}, cleared {cleared_ct}, unchanged {unmodified_ct}, took {delta:.3f} s', + add_log_data=True, +) +def finish_fact_cache(inventory, destination, facts_write_time, log_data, slice_number=0, slice_count=1, job_id=None): + log_data['inventory_id'] = inventory.id + log_data['updated_ct'] = 0 + log_data['unmodified_ct'] = 0 + log_data['cleared_ct'] = 0 + hosts_to_update = [] + for host in _get_inventory_hosts(inventory, slice_number, slice_count): + filepath = os.sep.join(map(str, [destination, host.name])) + if not os.path.realpath(filepath).startswith(destination): + system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) + continue + if os.path.exists(filepath): + # If the file changed since we wrote the last facts file, pre-playbook run... + modified = os.path.getmtime(filepath) + if (not facts_write_time) or modified > facts_write_time: + with codecs.open(filepath, 'r', encoding='utf-8') as f: + try: + ansible_facts = json.load(f) + except ValueError: + continue + host.ansible_facts = ansible_facts + host.ansible_facts_modified = now() + hosts_to_update.append(host) + system_tracking_logger.info( + 'New fact for inventory {} host {}'.format(smart_str(host.inventory.name), smart_str(host.name)), + extra=dict( + inventory_id=host.inventory.id, + host_name=host.name, + ansible_facts=host.ansible_facts, + ansible_facts_modified=host.ansible_facts_modified.isoformat(), + job_id=job_id, + ), + ) + log_data['updated_ct'] += 1 + else: + log_data['unmodified_ct'] += 1 + else: + # if the file goes missing, ansible removed it (likely via clear_facts) + host.ansible_facts = {} + host.ansible_facts_modified = now() + hosts_to_update.append(host) + system_tracking_logger.info('Facts cleared for inventory {} host {}'.format(smart_str(host.inventory.name), smart_str(host.name))) + log_data['cleared_ct'] += 1 + if len(hosts_to_update) > 100: + inventory.hosts.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) + hosts_to_update = [] + if hosts_to_update: + inventory.hosts.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index ef73caacf5..1bb886a557 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -63,6 +63,7 @@ from awx.main.tasks.callback import ( ) from awx.main.tasks.signals import with_signal_handling, signal_callback from awx.main.tasks.receptor import AWXReceptorJob +from awx.main.tasks.facts import start_fact_cache, finish_fact_cache from awx.main.exceptions import AwxTaskError, PostRunError, ReceptorNodeNotFound from awx.main.utils.ansible import read_ansible_config from awx.main.utils.execution_environments import CONTAINER_ROOT, to_container_path @@ -455,6 +456,9 @@ class BaseTask(object): instance.ansible_version = ansible_version_info instance.save(update_fields=['ansible_version']) + def should_use_fact_cache(self): + return False + @with_path_cleanup @with_signal_handling def run(self, pk, **kwargs): @@ -553,7 +557,8 @@ class BaseTask(object): params['module'] = self.build_module_name(self.instance) params['module_args'] = self.build_module_args(self.instance) - if getattr(self.instance, 'use_fact_cache', False): + # TODO: refactor into a better BasTask method + if self.should_use_fact_cache(): # Enable Ansible fact cache. params['fact_cache_type'] = 'jsonfile' else: @@ -1008,6 +1013,9 @@ class RunJob(SourceControlMixin, BaseTask): return args + def should_use_fact_cache(self): + return self.instance.use_fact_cache + def build_playbook_path_relative_to_cwd(self, job, private_data_dir): return job.playbook @@ -1073,8 +1081,14 @@ class RunJob(SourceControlMixin, BaseTask): # Fetch "cached" fact data from prior runs and put on the disk # where ansible expects to find it - if job.use_fact_cache: - self.facts_write_time = self.instance.start_job_fact_cache(os.path.join(private_data_dir, 'artifacts', str(job.id), 'fact_cache')) + if self.should_use_fact_cache(): + job.log_lifecycle("start_job_fact_cache") + self.facts_write_time = start_fact_cache( + job.inventory, + os.path.join(private_data_dir, 'artifacts', str(job.id), 'fact_cache'), + slice_number=job.job_slice_number, + slice_count=job.job_slice_count, + ) def build_project_dir(self, job, private_data_dir): self.sync_and_copy(job.project, private_data_dir, scm_branch=job.scm_branch) @@ -1088,10 +1102,15 @@ class RunJob(SourceControlMixin, BaseTask): # actual `run()` call; this _usually_ means something failed in # the pre_run_hook method return - if job.use_fact_cache: - job.finish_job_fact_cache( + if self.should_use_fact_cache(): + job.log_lifecycle("finish_job_fact_cache") + finish_fact_cache( + job.inventory, os.path.join(private_data_dir, 'artifacts', str(job.id), 'fact_cache'), - self.facts_write_time, + facts_write_time=self.facts_write_time, + slice_number=job.job_slice_number, + slice_count=job.job_slice_count, + job_id=job.id, ) def final_run_hook(self, job, status, private_data_dir): @@ -1529,11 +1548,14 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): # special case for constructed inventories, we pass source inventories from database # these must come in order, and in order _before_ the constructed inventory itself if inventory_update.inventory.kind == 'constructed': + inventory_update.log_lifecycle("start_job_fact_cache") for input_inventory in inventory_update.inventory.input_inventories.all(): args.append('-i') script_params = dict(hostvars=True, towervars=True) source_inv_path = self.write_inventory_file(input_inventory, private_data_dir, f'hosts_{input_inventory.id}', script_params) args.append(to_container_path(source_inv_path, private_data_dir)) + # Include any facts from input inventories so they can be used in filters + start_fact_cache(input_inventory, os.path.join(private_data_dir, 'artifacts', str(inventory_update.id), 'fact_cache')) # Add arguments for the source inventory file/script/thing rel_path = self.pseudo_build_inventory(inventory_update, private_data_dir) @@ -1562,6 +1584,9 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): return args + def should_use_fact_cache(self): + return bool(self.instance.source == 'constructed') + def build_inventory(self, inventory_update, private_data_dir): return None # what runner expects in order to not deal with inventory diff --git a/awx/main/tests/unit/models/test_jobs.py b/awx/main/tests/unit/models/test_jobs.py index 2f030a57c3..d17a434fb1 100644 --- a/awx/main/tests/unit/models/test_jobs.py +++ b/awx/main/tests/unit/models/test_jobs.py @@ -10,10 +10,12 @@ from awx.main.models import ( Inventory, Host, ) +from awx.main.tasks.facts import start_fact_cache, finish_fact_cache @pytest.fixture -def hosts(inventory): +def hosts(): + inventory = Inventory(id=5) return [ Host(name='host1', ansible_facts={"a": 1, "b": 2}, inventory=inventory), Host(name='host2', ansible_facts={"a": 1, "b": 2}, inventory=inventory), @@ -23,20 +25,21 @@ def hosts(inventory): @pytest.fixture -def inventory(): +def inventory(mocker, hosts): + mocker.patch('awx.main.tasks.facts._get_inventory_hosts', return_value=hosts) return Inventory(id=5) @pytest.fixture -def job(mocker, hosts, inventory): +def job(mocker, inventory): j = Job(inventory=inventory, id=2) - j._get_inventory_hosts = mocker.Mock(return_value=hosts) + # j._get_inventory_hosts = mocker.Mock(return_value=hosts) return j def test_start_job_fact_cache(hosts, job, inventory, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = job.start_job_fact_cache(fact_cache, timeout=0) + last_modified = start_fact_cache(inventory, fact_cache, timeout=0) for host in hosts: filepath = os.path.join(fact_cache, host.name) @@ -47,24 +50,25 @@ def test_start_job_fact_cache(hosts, job, inventory, tmpdir): def test_fact_cache_with_invalid_path_traversal(job, inventory, tmpdir, mocker): - job._get_inventory_hosts = mocker.Mock( + mocker.patch( + 'awx.main.tasks.facts._get_inventory_hosts', return_value=[ Host( name='../foo', ansible_facts={"a": 1, "b": 2}, ), - ] + ], ) fact_cache = os.path.join(tmpdir, 'facts') - job.start_job_fact_cache(fact_cache, timeout=0) + start_fact_cache(inventory, fact_cache, timeout=0) # a file called "foo" should _not_ be written outside the facts dir assert os.listdir(os.path.join(fact_cache, '..')) == ['facts'] def test_finish_job_fact_cache_with_existing_data(job, hosts, inventory, mocker, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = job.start_job_fact_cache(fact_cache, timeout=0) + last_modified = start_fact_cache(inventory, fact_cache, timeout=0) bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') @@ -80,7 +84,7 @@ def test_finish_job_fact_cache_with_existing_data(job, hosts, inventory, mocker, new_modification_time = time.time() + 3600 os.utime(filepath, (new_modification_time, new_modification_time)) - job.finish_job_fact_cache(fact_cache, last_modified) + finish_fact_cache(inventory, fact_cache, last_modified) for host in (hosts[0], hosts[2], hosts[3]): assert host.ansible_facts == {"a": 1, "b": 2} @@ -91,7 +95,7 @@ def test_finish_job_fact_cache_with_existing_data(job, hosts, inventory, mocker, def test_finish_job_fact_cache_with_bad_data(job, hosts, inventory, mocker, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = job.start_job_fact_cache(fact_cache, timeout=0) + last_modified = start_fact_cache(inventory, fact_cache, timeout=0) bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') @@ -103,19 +107,19 @@ def test_finish_job_fact_cache_with_bad_data(job, hosts, inventory, mocker, tmpd new_modification_time = time.time() + 3600 os.utime(filepath, (new_modification_time, new_modification_time)) - job.finish_job_fact_cache(fact_cache, last_modified) + finish_fact_cache(inventory, fact_cache, last_modified) bulk_update.assert_not_called() def test_finish_job_fact_cache_clear(job, hosts, inventory, mocker, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = job.start_job_fact_cache(fact_cache, timeout=0) + last_modified = start_fact_cache(inventory, fact_cache, timeout=0) bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') os.remove(os.path.join(fact_cache, hosts[1].name)) - job.finish_job_fact_cache(fact_cache, last_modified) + finish_fact_cache(inventory, fact_cache, last_modified) for host in (hosts[0], hosts[2], hosts[3]): assert host.ansible_facts == {"a": 1, "b": 2} From 84edbed5ec5db7342af6137f1c9fbd9632097f33 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 21 Mar 2023 13:14:44 -0500 Subject: [PATCH 26/33] [constructed-inventory] Fix some validation for constructed inv sources (#13727) - When updating, we need the original object so we can make sure we aren't changing things we shouldn't be. - We want to allow source_vars and limit, but not much else. - We want to block everything else (at least, if it doesn't match what is in the original object...to allow the collection to work properly). - Add two functional tests. Signed-off-by: Rick Elrod --- awx/api/serializers.py | 22 +++++++--- .../tests/functional/api/test_inventory.py | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d02a486f5c..0e0ad53aff 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -191,6 +191,11 @@ SUMMARIZABLE_FK_FIELDS = { } +# These fields can be edited on a constructed inventory's generated source (possibly by using the constructed +# inventory's special API endpoint, but also by using the inventory sources endpoint). +CONSTRUCTED_INVENTORY_SOURCE_EDITABLE_FIELDS = ('source_vars', 'update_cache_timeout', 'limit', 'verbosity') + + def reverse_gfk(content_object, request): """ Computes a reverse for a GenericForeignKey field. @@ -1784,12 +1789,12 @@ class ConstructedInventorySerializer(InventorySerializer): class Meta: model = Inventory - fields = ('*', '-host_filter', 'source_vars', 'update_cache_timeout', 'limit', 'verbosity') + fields = ('*', '-host_filter') + CONSTRUCTED_INVENTORY_SOURCE_EDITABLE_FIELDS read_only_fields = ('*', 'kind') def pop_inv_src_data(self, data): inv_src_data = {} - for field in ('source_vars', 'update_cache_timeout', 'limit', 'verbosity'): + for field in CONSTRUCTED_INVENTORY_SOURCE_EDITABLE_FIELDS: if field in data: # values always need to be removed, as they are not valid for Inventory model value = data.pop(field) @@ -2361,8 +2366,6 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt obj = super(InventorySourceSerializer, self).update(obj, validated_data) if deprecated_fields: self._update_deprecated_fields(deprecated_fields, obj) - if obj.source == 'constructed': - raise serializers.ValidationError({'error': _("Cannot edit source of type constructed.")}) return obj # TODO: remove when old 'credential' fields are removed @@ -2386,11 +2389,16 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt def get_field_from_model_or_attrs(fd): return attrs.get(fd, self.instance and getattr(self.instance, fd) or None) - if get_field_from_model_or_attrs('source') == 'scm': + if self.instance and self.instance.source == 'constructed': + allowed_fields = CONSTRUCTED_INVENTORY_SOURCE_EDITABLE_FIELDS + for field in attrs: + if attrs[field] != getattr(self.instance, field) and field not in allowed_fields: + raise serializers.ValidationError({"error": _("Cannot change field '{}' on a constructed inventory source.").format(field)}) + elif get_field_from_model_or_attrs('source') == 'scm': if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None: raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")}) - elif (get_field_from_model_or_attrs('source') == 'constructed') and (self.instance and self.instance.source != 'constructed'): - raise serializers.ValidationError({"Error": _('constructed not a valid source for inventory')}) + elif get_field_from_model_or_attrs('source') == 'constructed': + raise serializers.ValidationError({"error": _('constructed not a valid source for inventory')}) else: redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path', 'scm_branch'])) if redundant_scm_fields: diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index c8139a340a..ab84ff236d 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -624,6 +624,50 @@ class TestConstructedInventory: assert inv_src.update_cache_timeout == 54 assert inv_src.limit == 'foobar' + def test_patch_constructed_inventory_generated_source_limits_editable_fields(self, constructed_inventory, admin_user, project, patch): + inv_src = constructed_inventory.inventory_sources.first() + r = patch( + url=inv_src.get_absolute_url(), + data={ + 'source': 'scm', + 'source_project': project.pk, + 'source_path': '', + 'source_vars': 'plugin: a.b.c', + }, + expect=400, + user=admin_user, + ) + assert str(r.data['error'][0]) == "Cannot change field 'source' on a constructed inventory source." + + # Make sure it didn't get updated before we got the error + inv_src_after_err = constructed_inventory.inventory_sources.first() + assert inv_src.id == inv_src_after_err.id + assert inv_src.source == inv_src_after_err.source + assert inv_src.source_project == inv_src_after_err.source_project + assert inv_src.source_path == inv_src_after_err.source_path + assert inv_src.source_vars == inv_src_after_err.source_vars + + def test_patch_constructed_inventory_generated_source_allows_source_vars_edit(self, constructed_inventory, admin_user, patch): + inv_src = constructed_inventory.inventory_sources.first() + patch( + url=inv_src.get_absolute_url(), + data={ + 'source_vars': 'plugin: a.b.c', + }, + expect=200, + user=admin_user, + ) + + inv_src_after_patch = constructed_inventory.inventory_sources.first() + + # sanity checks + assert inv_src.id == inv_src_after_patch.id + assert inv_src.source == 'constructed' + assert inv_src_after_patch.source == 'constructed' + assert inv_src.source_vars == '' + + assert inv_src_after_patch.source_vars == 'plugin: a.b.c' + def test_create_constructed_inventory(self, constructed_inventory, admin_user, post, organization): r = post( url=reverse('api:constructed_inventory_list'), From be5a2bbe612f50f61cd6f7e06d4ef2d0e1355ce8 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 21 Mar 2023 14:15:28 -0400 Subject: [PATCH 27/33] Fail inventory updates with unmatched limits (#13726) --- awx/main/constants.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/main/constants.py b/awx/main/constants.py index 85a14cca4c..8450c9145e 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -38,6 +38,8 @@ STANDARD_INVENTORY_UPDATE_ENV = { 'ANSIBLE_INVENTORY_EXPORT': 'True', # Redirecting output to stderr allows JSON parsing to still work with -vvv 'ANSIBLE_VERBOSE_TO_STDERR': 'True', + # if ansible-inventory --limit is used for an inventory import, unmatched should be a failure + 'ANSIBLE_HOST_PATTERN_MISMATCH': 'error', } CAN_CANCEL = ('new', 'pending', 'waiting', 'running') ACTIVE_STATES = CAN_CANCEL From 62b79b19594a24d6344384f0fc5774140085fabc Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 21 Mar 2023 14:54:03 -0400 Subject: [PATCH 28/33] Point constructed inventory URL to special view (#13730) --- awx/main/models/inventory.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index c8f609b2ca..6bc03190a2 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -206,6 +206,8 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): ) def get_absolute_url(self, request=None): + if self.kind == 'constructed': + return reverse('api:constructed_inventory_detail', kwargs={'pk': self.pk}, request=request) return reverse('api:inventory_detail', kwargs={'pk': self.pk}, request=request) variables_dict = VarsDictProperty('variables') From b88d9f47315cf9d4d303c4d3f7ca7da2180f36a2 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Tue, 21 Mar 2023 14:55:24 -0400 Subject: [PATCH 29/33] Force overwrite all vars for constructed inventory (#13731) --- .../management/commands/inventory_import.py | 51 ++++++++++++------- awx/main/models/inventory.py | 4 +- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 8150936054..582af9d03b 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -458,12 +458,19 @@ class Command(BaseCommand): # TODO: We disable variable overwrite here in case user-defined inventory variables get # mangled. But we still need to figure out a better way of processing multiple inventory # update variables mixing with each other. - all_obj = self.inventory - db_variables = all_obj.variables_dict - db_variables.update(self.all_group.variables) - if db_variables != all_obj.variables_dict: - all_obj.variables = json.dumps(db_variables) - all_obj.save(update_fields=['variables']) + # issue for this: https://github.com/ansible/awx/issues/11623 + + if self.inventory.kind == 'constructed' and self.inventory_source.overwrite_vars: + # NOTE: we had to add a exception case to not merge variables + # to make constructed inventory coherent + db_variables = self.all_group.variables + else: + db_variables = self.inventory.variables_dict + db_variables.update(self.all_group.variables) + + if db_variables != self.inventory.variables_dict: + self.inventory.variables = json.dumps(db_variables) + self.inventory.save(update_fields=['variables']) logger.debug('Inventory variables updated from "all" group') else: logger.debug('Inventory variables unmodified') @@ -522,16 +529,32 @@ class Command(BaseCommand): def _update_db_host_from_mem_host(self, db_host, mem_host): # Update host variables. db_variables = db_host.variables_dict - if self.overwrite_vars: - db_variables = mem_host.variables - else: - db_variables.update(mem_host.variables) + mem_variables = mem_host.variables update_fields = [] + + # Update host instance_id. + instance_id = self._get_instance_id(mem_variables) + if instance_id != db_host.instance_id: + old_instance_id = db_host.instance_id + db_host.instance_id = instance_id + update_fields.append('instance_id') + + if self.inventory.kind == 'constructed': + # remote towervars so the constructed hosts do not have extra variables + for prefix in ('host', 'tower'): + for var in ('remote_{}_enabled', 'remote_{}_id'): + mem_variables.pop(var.format(prefix), None) + + if self.overwrite_vars: + db_variables = mem_variables + else: + db_variables.update(mem_variables) + if db_variables != db_host.variables_dict: db_host.variables = json.dumps(db_variables) update_fields.append('variables') # Update host enabled flag. - enabled = self._get_enabled(mem_host.variables) + enabled = self._get_enabled(mem_variables) if enabled is not None and db_host.enabled != enabled: db_host.enabled = enabled update_fields.append('enabled') @@ -540,12 +563,6 @@ class Command(BaseCommand): old_name = db_host.name db_host.name = mem_host.name update_fields.append('name') - # Update host instance_id. - instance_id = self._get_instance_id(mem_host.variables) - if instance_id != db_host.instance_id: - old_instance_id = db_host.instance_id - db_host.instance_id = instance_id - update_fields.append('instance_id') # Update host and display message(s) on what changed. if update_fields: db_host.save(update_fields=update_fields) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 6bc03190a2..a4ddaabafd 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -458,7 +458,9 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): """ if self.kind == 'constructed': if not self.inventory_sources.exists(): - self.inventory_sources.create(source='constructed', name=f'Auto-created source for: {self.name}'[:512], overwrite=True, update_on_launch=True) + self.inventory_sources.create( + source='constructed', name=f'Auto-created source for: {self.name}'[:512], overwrite=True, overwrite_vars=True, update_on_launch=True + ) def save(self, *args, **kwargs): self._update_host_smart_inventory_memeberships() From 3f5a4cb6f104825656880eccc014f90dd478f2b6 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Wed, 22 Mar 2023 14:04:25 -0400 Subject: [PATCH 30/33] [constructed-inventory] Backlink events to real hosts and summaries to both hosts (#13718) * Backlink events to real hosts and summaries to both hosts * Prevent error when original host is deleted during job run * No duplicate entries, review suggestion from Rick * Change word tense in help text, dict style adjustments From code review Co-authored-by: Rick Elrod * Back out new variable for constructed host id --------- Co-authored-by: Rick Elrod --- awx/api/serializers.py | 8 +++++++- .../migrations/0182_constructed_inventory.py | 13 ++++++++++++ awx/main/models/events.py | 20 ++++++++++++++++--- awx/main/models/jobs.py | 16 +++++++++------ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 0e0ad53aff..de5c0661fc 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -158,6 +158,7 @@ SUMMARIZABLE_FK_FIELDS = { 'kind', ), 'host': DEFAULT_SUMMARY_FIELDS, + 'constructed_host': DEFAULT_SUMMARY_FIELDS, 'group': DEFAULT_SUMMARY_FIELDS, 'default_environment': DEFAULT_SUMMARY_FIELDS + ('image',), 'execution_environment': DEFAULT_SUMMARY_FIELDS + ('image',), @@ -1903,6 +1904,10 @@ class HostSerializer(BaseSerializerWithVariables): group_list = [{'id': g.id, 'name': g.name} for g in obj.groups.all().order_by('id')[:5]] group_cnt = obj.groups.count() d.setdefault('groups', {'count': group_cnt, 'results': group_list}) + if obj.inventory.kind == 'constructed': + summaries_qs = obj.constructed_host_summaries + else: + summaries_qs = obj.job_host_summaries d.setdefault( 'recent_jobs', [ @@ -1913,7 +1918,7 @@ class HostSerializer(BaseSerializerWithVariables): 'status': j.job.status, 'finished': j.job.finished, } - for j in obj.job_host_summaries.select_related('job__job_template').order_by('-created').defer('job__extra_vars', 'job__artifacts')[:5] + for j in summaries_qs.select_related('job__job_template').order_by('-created').defer('job__extra_vars', 'job__artifacts')[:5] ], ) return d @@ -4140,6 +4145,7 @@ class JobHostSummarySerializer(BaseSerializer): '-description', 'job', 'host', + 'constructed_host', 'host_name', 'changed', 'dark', diff --git a/awx/main/migrations/0182_constructed_inventory.py b/awx/main/migrations/0182_constructed_inventory.py index 10ece7b2f4..54ef7f1328 100644 --- a/awx/main/migrations/0182_constructed_inventory.py +++ b/awx/main/migrations/0182_constructed_inventory.py @@ -122,4 +122,17 @@ class Migration(migrations.Migration): help_text='This field is deprecated and will be removed in a future release. Regex where only matching hosts will be imported.', ), ), + migrations.AddField( + model_name='jobhostsummary', + name='constructed_host', + field=models.ForeignKey( + default=None, + editable=False, + help_text='Only for jobs run against constructed inventories, this links to the host inside the constructed inventory.', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='constructed_host_summaries', + to='main.host', + ), + ), ] diff --git a/awx/main/models/events.py b/awx/main/models/events.py index 2d6dee6f61..c28c5de89b 100644 --- a/awx/main/models/events.py +++ b/awx/main/models/events.py @@ -7,6 +7,7 @@ from collections import defaultdict from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import models, DatabaseError +from django.db.models.functions import Cast from django.utils.dateparse import parse_datetime from django.utils.text import Truncator from django.utils.timezone import utc, now @@ -538,23 +539,36 @@ class JobEvent(BasePlaybookEvent): from awx.main.models import Host, JobHostSummary # circular import - all_hosts = Host.objects.filter(pk__in=self.host_map.values()).only('id', 'name') + if self.job.inventory.kind == 'constructed': + all_hosts = Host.objects.filter(id__in=self.job.inventory.hosts.values_list(Cast('instance_id', output_field=models.IntegerField()))).only( + 'id', 'name' + ) + constructed_host_map = self.host_map + host_map = {host.name: host.id for host in all_hosts} + else: + all_hosts = Host.objects.filter(pk__in=self.host_map.values()).only('id', 'name') + constructed_host_map = {} + host_map = self.host_map + existing_host_ids = set(h.id for h in all_hosts) summaries = dict() updated_hosts_list = list() for host in hostnames: updated_hosts_list.append(host.lower()) - host_id = self.host_map.get(host, None) + host_id = host_map.get(host) if host_id not in existing_host_ids: host_id = None + constructed_host_id = constructed_host_map.get(host) host_stats = {} for stat in ('changed', 'dark', 'failures', 'ignored', 'ok', 'processed', 'rescued', 'skipped'): try: host_stats[stat] = self.event_data.get(stat, {}).get(host, 0) except AttributeError: # in case event_data[stat] isn't a dict. pass - summary = JobHostSummary(created=now(), modified=now(), job_id=job.id, host_id=host_id, host_name=host, **host_stats) + summary = JobHostSummary( + created=now(), modified=now(), job_id=job.id, host_id=host_id, constructed_host_id=constructed_host_id, host_name=host, **host_stats + ) summary.failed = bool(summary.dark or summary.failures) summaries[(host_id, host)] = summary diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index ae9e66ae5f..daad187d97 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -569,12 +569,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana default=None, on_delete=models.SET_NULL, ) - hosts = models.ManyToManyField( - 'Host', - related_name='jobs', - editable=False, - through='JobHostSummary', - ) + hosts = models.ManyToManyField('Host', related_name='jobs', editable=False, through='JobHostSummary', through_fields=('job', 'host')) artifacts = JSONBlob( default=dict, blank=True, @@ -1059,6 +1054,15 @@ class JobHostSummary(CreatedModifiedModel): editable=False, ) host = models.ForeignKey('Host', related_name='job_host_summaries', null=True, default=None, on_delete=models.SET_NULL, editable=False) + constructed_host = models.ForeignKey( + 'Host', + related_name='constructed_host_summaries', + null=True, + default=None, + on_delete=models.SET_NULL, + editable=False, + help_text='Only for jobs run against constructed inventories, this links to the host inside the constructed inventory.', + ) host_name = models.CharField( max_length=1024, From 16ad27099ea3d886ddc4b2637908398383e92559 Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Sat, 25 Mar 2023 21:43:41 -0400 Subject: [PATCH 31/33] [constructed-inventory] Save facts on model for original host (#13700) * Save facts on model for original host Redirect to original host for ansible facts Use current inventory hosts for facts instance_id filter Thanks for Gabe for identifying this bug * Fix spelling of queryset Co-authored-by: Rick Elrod * Fix sign error with facts expiry - from review --------- Co-authored-by: Rick Elrod --- awx/api/serializers.py | 1 + awx/api/views/__init__.py | 10 ++- awx/main/constants.py | 3 + awx/main/models/jobs.py | 23 ++++++ awx/main/tasks/facts.py | 43 ++++------ awx/main/tasks/jobs.py | 17 ++-- awx/main/tests/unit/models/test_jobs.py | 100 ++++++++++++++---------- 7 files changed, 119 insertions(+), 78 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index de5c0661fc..4b3a62c841 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1883,6 +1883,7 @@ class HostSerializer(BaseSerializerWithVariables): ) if obj.inventory.kind == 'constructed': res['original_host'] = self.reverse('api:host_detail', kwargs={'pk': obj.instance_id}) + res['ansible_facts'] = self.reverse('api:host_ansible_facts_detail', kwargs={'pk': obj.instance_id}) if obj.inventory: res['inventory'] = self.reverse('api:inventory_detail', kwargs={'pk': obj.inventory.pk}) if obj.last_job: diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index d023f92984..e7f1d5cf8a 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -29,7 +29,7 @@ from django.utils.safestring import mark_safe from django.utils.timezone import now from django.views.decorators.csrf import csrf_exempt from django.template.loader import render_to_string -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ @@ -1619,6 +1619,14 @@ class HostAnsibleFactsDetail(RetrieveAPIView): model = models.Host serializer_class = serializers.AnsibleFactsSerializer + def get(self, request, *args, **kwargs): + obj = self.get_object() + if obj.inventory.kind == 'constructed': + # If this is a constructed inventory host, it is not the source of truth about facts + # redirect to the original input inventory host instead + return HttpResponseRedirect(reverse('api:host_ansible_facts_detail', kwargs={'pk': obj.instance_id}, request=self.request)) + return super().get(request, *args, **kwargs) + class InventoryHostsList(HostRelatedSearchMixin, SubListCreateAttachDetachAPIView): model = models.Host diff --git a/awx/main/constants.py b/awx/main/constants.py index 8450c9145e..32d8a2184c 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -111,3 +111,6 @@ ANSIBLE_RUNNER_NEEDS_UPDATE_MESSAGE = ( # Values for setting SUBSCRIPTION_USAGE_MODEL SUBSCRIPTION_USAGE_MODEL_UNIQUE_HOSTS = 'unique_managed_hosts' + +# Shared prefetch to use for creating a queryset for the purpose of writing or saving facts +HOST_FACTS_FIELDS = ('name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id') diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index daad187d97..5e55683c20 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -11,6 +11,7 @@ from urllib.parse import urljoin from django.conf import settings from django.core.exceptions import ValidationError from django.db import models +from django.db.models.functions import Cast # from django.core.cache import cache from django.utils.translation import gettext_lazy as _ @@ -21,6 +22,7 @@ from rest_framework.exceptions import ParseError # AWX from awx.api.versioning import reverse +from awx.main.constants import HOST_FACTS_FIELDS from awx.main.models.base import ( BaseModel, CreatedModifiedModel, @@ -834,6 +836,27 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana def get_notification_friendly_name(self): return "Job" + def get_hosts_for_fact_cache(self): + """ + Builds the queryset to use for writing or finalizing the fact cache + these need to be the 'real' hosts associated with the job. + For constructed inventories, that means the original (input inventory) hosts + when slicing, that means only returning hosts in that slice + """ + Host = JobHostSummary._meta.get_field('host').related_model + if not self.inventory_id: + return Host.objects.none() + + if self.inventory.kind == 'constructed': + id_field = Host._meta.get_field('id') + host_qs = Host.objects.filter(id__in=self.inventory.hosts.exclude(instance_id='').values_list(Cast('instance_id', output_field=id_field))) + else: + host_qs = self.inventory.hosts + + host_qs = host_qs.only(*HOST_FACTS_FIELDS) + host_qs = self.inventory.get_sliced_hosts(host_qs, self.job_slice_number, self.job_slice_count) + return host_qs + class LaunchTimeConfigBase(BaseModel): """ diff --git a/awx/main/tasks/facts.py b/awx/main/tasks/facts.py index ba48bc2249..3db5f13091 100644 --- a/awx/main/tasks/facts.py +++ b/awx/main/tasks/facts.py @@ -12,28 +12,16 @@ from django.utils.timezone import now # AWX from awx.main.utils.common import log_excess_runtime +from awx.main.models.inventory import Host logger = logging.getLogger('awx.main.tasks.facts') system_tracking_logger = logging.getLogger('awx.analytics.system_tracking') -def _get_inventory_hosts(inventory, slice_number, slice_count, only=('name', 'ansible_facts', 'ansible_facts_modified', 'modified', 'inventory_id'), **filters): - """Return value is an iterable for the relevant hosts for this job""" - if not inventory: - return [] - host_queryset = inventory.hosts.only(*only) - if filters: - host_queryset = host_queryset.filter(**filters) - host_queryset = inventory.get_sliced_hosts(host_queryset, slice_number, slice_count) - if isinstance(host_queryset, QuerySet): - return host_queryset.iterator() - return host_queryset - - @log_excess_runtime(logger, debug_cutoff=0.01, msg='Inventory {inventory_id} host facts prepared for {written_ct} hosts, took {delta:.3f} s', add_log_data=True) -def start_fact_cache(inventory, destination, log_data, timeout=None, slice_number=0, slice_count=1): - log_data['inventory_id'] = inventory.id +def start_fact_cache(hosts, destination, log_data, timeout=None, inventory_id=None): + log_data['inventory_id'] = inventory_id log_data['written_ct'] = 0 try: os.makedirs(destination, mode=0o700) @@ -42,15 +30,14 @@ def start_fact_cache(inventory, destination, log_data, timeout=None, slice_numbe if timeout is None: timeout = settings.ANSIBLE_FACT_CACHE_TIMEOUT - if timeout > 0: - # exclude hosts with fact data older than `settings.ANSIBLE_FACT_CACHE_TIMEOUT seconds` - timeout = now() - datetime.timedelta(seconds=timeout) - hosts = _get_inventory_hosts(inventory, slice_number, slice_count, ansible_facts_modified__gte=timeout) - else: - hosts = _get_inventory_hosts(inventory, slice_number, slice_count) + + if isinstance(hosts, QuerySet): + hosts = hosts.iterator() last_filepath_written = None for host in hosts: + if (not host.ansible_facts_modified) or (timeout and host.ansible_facts_modified < now() - datetime.timedelta(seconds=timeout)): + continue # facts are expired - do not write them filepath = os.sep.join(map(str, [destination, host.name])) if not os.path.realpath(filepath).startswith(destination): system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) @@ -76,13 +63,17 @@ def start_fact_cache(inventory, destination, log_data, timeout=None, slice_numbe msg='Inventory {inventory_id} host facts: updated {updated_ct}, cleared {cleared_ct}, unchanged {unmodified_ct}, took {delta:.3f} s', add_log_data=True, ) -def finish_fact_cache(inventory, destination, facts_write_time, log_data, slice_number=0, slice_count=1, job_id=None): - log_data['inventory_id'] = inventory.id +def finish_fact_cache(hosts, destination, facts_write_time, log_data, job_id=None, inventory_id=None): + log_data['inventory_id'] = inventory_id log_data['updated_ct'] = 0 log_data['unmodified_ct'] = 0 log_data['cleared_ct'] = 0 + + if isinstance(hosts, QuerySet): + hosts = hosts.iterator() + hosts_to_update = [] - for host in _get_inventory_hosts(inventory, slice_number, slice_count): + for host in hosts: filepath = os.sep.join(map(str, [destination, host.name])) if not os.path.realpath(filepath).startswith(destination): system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) @@ -120,7 +111,7 @@ def finish_fact_cache(inventory, destination, facts_write_time, log_data, slice_ system_tracking_logger.info('Facts cleared for inventory {} host {}'.format(smart_str(host.inventory.name), smart_str(host.name))) log_data['cleared_ct'] += 1 if len(hosts_to_update) > 100: - inventory.hosts.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) + Host.objects.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) hosts_to_update = [] if hosts_to_update: - inventory.hosts.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) + Host.objects.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 1bb886a557..74286faa20 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -37,6 +37,7 @@ from awx.main.constants import ( MAX_ISOLATED_PATH_COLON_DELIMITER, CONTAINER_VOLUMES_MOUNT_TYPES, ACTIVE_STATES, + HOST_FACTS_FIELDS, ) from awx.main.models import ( Instance, @@ -1084,10 +1085,7 @@ class RunJob(SourceControlMixin, BaseTask): if self.should_use_fact_cache(): job.log_lifecycle("start_job_fact_cache") self.facts_write_time = start_fact_cache( - job.inventory, - os.path.join(private_data_dir, 'artifacts', str(job.id), 'fact_cache'), - slice_number=job.job_slice_number, - slice_count=job.job_slice_count, + job.get_hosts_for_fact_cache(), os.path.join(private_data_dir, 'artifacts', str(job.id), 'fact_cache'), inventory_id=job.inventory_id ) def build_project_dir(self, job, private_data_dir): @@ -1105,12 +1103,11 @@ class RunJob(SourceControlMixin, BaseTask): if self.should_use_fact_cache(): job.log_lifecycle("finish_job_fact_cache") finish_fact_cache( - job.inventory, + job.get_hosts_for_fact_cache(), os.path.join(private_data_dir, 'artifacts', str(job.id), 'fact_cache'), facts_write_time=self.facts_write_time, - slice_number=job.job_slice_number, - slice_count=job.job_slice_count, job_id=job.id, + inventory_id=job.inventory_id, ) def final_run_hook(self, job, status, private_data_dir): @@ -1555,7 +1552,11 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask): source_inv_path = self.write_inventory_file(input_inventory, private_data_dir, f'hosts_{input_inventory.id}', script_params) args.append(to_container_path(source_inv_path, private_data_dir)) # Include any facts from input inventories so they can be used in filters - start_fact_cache(input_inventory, os.path.join(private_data_dir, 'artifacts', str(inventory_update.id), 'fact_cache')) + start_fact_cache( + input_inventory.hosts.only(*HOST_FACTS_FIELDS), + os.path.join(private_data_dir, 'artifacts', str(inventory_update.id), 'fact_cache'), + inventory_id=input_inventory.id, + ) # Add arguments for the source inventory file/script/thing rel_path = self.pseudo_build_inventory(inventory_update, private_data_dir) diff --git a/awx/main/tests/unit/models/test_jobs.py b/awx/main/tests/unit/models/test_jobs.py index d17a434fb1..4f05a82535 100644 --- a/awx/main/tests/unit/models/test_jobs.py +++ b/awx/main/tests/unit/models/test_jobs.py @@ -6,40 +6,35 @@ import time import pytest from awx.main.models import ( - Job, Inventory, Host, ) from awx.main.tasks.facts import start_fact_cache, finish_fact_cache +from django.utils.timezone import now + +from datetime import timedelta + @pytest.fixture -def hosts(): +def ref_time(): + return now() - timedelta(seconds=5) + + +@pytest.fixture +def hosts(ref_time): inventory = Inventory(id=5) return [ - Host(name='host1', ansible_facts={"a": 1, "b": 2}, inventory=inventory), - Host(name='host2', ansible_facts={"a": 1, "b": 2}, inventory=inventory), - Host(name='host3', ansible_facts={"a": 1, "b": 2}, inventory=inventory), - Host(name=u'Iñtërnâtiônàlizætiøn', ansible_facts={"a": 1, "b": 2}, inventory=inventory), + Host(name='host1', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=ref_time, inventory=inventory), + Host(name='host2', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=ref_time, inventory=inventory), + Host(name='host3', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=ref_time, inventory=inventory), + Host(name=u'Iñtërnâtiônàlizætiøn', ansible_facts={"a": 1, "b": 2}, ansible_facts_modified=ref_time, inventory=inventory), ] -@pytest.fixture -def inventory(mocker, hosts): - mocker.patch('awx.main.tasks.facts._get_inventory_hosts', return_value=hosts) - return Inventory(id=5) - - -@pytest.fixture -def job(mocker, inventory): - j = Job(inventory=inventory, id=2) - # j._get_inventory_hosts = mocker.Mock(return_value=hosts) - return j - - -def test_start_job_fact_cache(hosts, job, inventory, tmpdir): +def test_start_job_fact_cache(hosts, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(inventory, fact_cache, timeout=0) + last_modified = start_fact_cache(hosts, fact_cache, timeout=0) for host in hosts: filepath = os.path.join(fact_cache, host.name) @@ -49,26 +44,43 @@ def test_start_job_fact_cache(hosts, job, inventory, tmpdir): assert os.path.getmtime(filepath) <= last_modified -def test_fact_cache_with_invalid_path_traversal(job, inventory, tmpdir, mocker): - mocker.patch( - 'awx.main.tasks.facts._get_inventory_hosts', - return_value=[ - Host( - name='../foo', - ansible_facts={"a": 1, "b": 2}, - ), - ], - ) +def test_fact_cache_with_invalid_path_traversal(tmpdir): + hosts = [ + Host( + name='../foo', + ansible_facts={"a": 1, "b": 2}, + ), + ] fact_cache = os.path.join(tmpdir, 'facts') - start_fact_cache(inventory, fact_cache, timeout=0) + start_fact_cache(hosts, fact_cache, timeout=0) # a file called "foo" should _not_ be written outside the facts dir assert os.listdir(os.path.join(fact_cache, '..')) == ['facts'] -def test_finish_job_fact_cache_with_existing_data(job, hosts, inventory, mocker, tmpdir): +def test_start_job_fact_cache_past_timeout(hosts, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(inventory, fact_cache, timeout=0) + # the hosts fixture was modified 5s ago, which is more than 2s + last_modified = start_fact_cache(hosts, fact_cache, timeout=2) + assert last_modified is None + + for host in hosts: + assert not os.path.exists(os.path.join(fact_cache, host.name)) + + +def test_start_job_fact_cache_within_timeout(hosts, tmpdir): + fact_cache = os.path.join(tmpdir, 'facts') + # the hosts fixture was modified 5s ago, which is less than 7s + last_modified = start_fact_cache(hosts, fact_cache, timeout=7) + assert last_modified + + for host in hosts: + assert os.path.exists(os.path.join(fact_cache, host.name)) + + +def test_finish_job_fact_cache_with_existing_data(hosts, mocker, tmpdir, ref_time): + fact_cache = os.path.join(tmpdir, 'facts') + last_modified = start_fact_cache(hosts, fact_cache, timeout=0) bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') @@ -84,18 +96,19 @@ def test_finish_job_fact_cache_with_existing_data(job, hosts, inventory, mocker, new_modification_time = time.time() + 3600 os.utime(filepath, (new_modification_time, new_modification_time)) - finish_fact_cache(inventory, fact_cache, last_modified) + finish_fact_cache(hosts, fact_cache, last_modified) for host in (hosts[0], hosts[2], hosts[3]): assert host.ansible_facts == {"a": 1, "b": 2} - assert host.ansible_facts_modified is None + assert host.ansible_facts_modified == ref_time assert hosts[1].ansible_facts == ansible_facts_new + assert hosts[1].ansible_facts_modified > ref_time bulk_update.assert_called_once_with([hosts[1]], ['ansible_facts', 'ansible_facts_modified']) -def test_finish_job_fact_cache_with_bad_data(job, hosts, inventory, mocker, tmpdir): +def test_finish_job_fact_cache_with_bad_data(hosts, mocker, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(inventory, fact_cache, timeout=0) + last_modified = start_fact_cache(hosts, fact_cache, timeout=0) bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') @@ -107,22 +120,23 @@ def test_finish_job_fact_cache_with_bad_data(job, hosts, inventory, mocker, tmpd new_modification_time = time.time() + 3600 os.utime(filepath, (new_modification_time, new_modification_time)) - finish_fact_cache(inventory, fact_cache, last_modified) + finish_fact_cache(hosts, fact_cache, last_modified) bulk_update.assert_not_called() -def test_finish_job_fact_cache_clear(job, hosts, inventory, mocker, tmpdir): +def test_finish_job_fact_cache_clear(hosts, mocker, ref_time, tmpdir): fact_cache = os.path.join(tmpdir, 'facts') - last_modified = start_fact_cache(inventory, fact_cache, timeout=0) + last_modified = start_fact_cache(hosts, fact_cache, timeout=0) bulk_update = mocker.patch('django.db.models.query.QuerySet.bulk_update') os.remove(os.path.join(fact_cache, hosts[1].name)) - finish_fact_cache(inventory, fact_cache, last_modified) + finish_fact_cache(hosts, fact_cache, last_modified) for host in (hosts[0], hosts[2], hosts[3]): assert host.ansible_facts == {"a": 1, "b": 2} - assert host.ansible_facts_modified is None + assert host.ansible_facts_modified == ref_time assert hosts[1].ansible_facts == {} + assert hosts[1].ansible_facts_modified > ref_time bulk_update.assert_called_once_with([hosts[1]], ['ansible_facts', 'ansible_facts_modified']) From f792fea048c26678491b81a2e50cfec2919ddfce Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 27 Mar 2023 15:01:35 -0400 Subject: [PATCH 32/33] Add more constructed inventory hint examples --- .../shared/ConstructedInventoryHint.js | 317 ++++++++++++++---- .../shared/ConstructedInventoryHint.test.js | 18 +- 2 files changed, 268 insertions(+), 67 deletions(-) diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js index 34a3ca48f1..be22d4ba13 100644 --- a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js @@ -3,10 +3,17 @@ import { t } from '@lingui/macro'; import { Alert, AlertActionLink, + ClipboardCopyButton, CodeBlock, CodeBlockAction, CodeBlockCode, - ClipboardCopyButton, + ClipboardCopy, + Form, + FormFieldGroupExpandable, + FormFieldGroupHeader, + FormGroup, + Panel, + CardBody, } from '@patternfly/react-core'; import { TableComposable, @@ -22,25 +29,6 @@ import { useConfig } from 'contexts/Config'; function ConstructedInventoryHint() { const config = useConfig(); - const [copied, setCopied] = React.useState(false); - - const clipboardCopyFunc = (event, text) => { - navigator.clipboard.writeText(text.toString()); - }; - - const onClick = (event, text) => { - clipboardCopyFunc(event, text); - setCopied(true); - }; - - const pluginSample = `plugin: constructed -strict: true -use_vars_plugins: true -groups: - shutdown: resolved_state == "shutdown" - shutdown_in_product_dev: resolved_state == "shutdown" and account_alias == "product_dev" -compose: - resolved_state: state | default("running")`; return ( - {t`View constructed plugin documentation here`}{' '} + {t`View constructed inventory documentation here`}{' '} } > - {t`WIP - More to come...`} + + {t`This table gives a few useful parameters of the constructed + inventory plugin. For the full list of parameters `}{' '} + {t`view the constructed inventory plugin docs here.`} +

{t`required`}

- {t`Token that ensures this is a source file + {t`Token that ensures this is a source file for the ‘constructed’ plugin.`} @@ -95,23 +89,11 @@ compose: {t`If yes make invalid entries a fatal error, otherwise skip and continue.`}{' '}
- {t`If users need feedback about the correctness - of their constructed groups, it is highly recommended + {t`If users need feedback about the correctness + of their constructed groups, it is highly recommended to use strict: true in the plugin configuration.`} - - - use_vars_plugins -

{t`string`}

- - - {t`Normally, for performance reasons, vars plugins get - executed after the inventory sources complete the - base inventory, this option allows for getting vars - related to hosts/groups from those plugins.`} - - groups @@ -127,38 +109,251 @@ compose:

{t`dictionary`}

- {t`Create vars from jinja2 expressions.`} + {t`Create vars from jinja2 expressions. This can be useful + if the constructed groups you define do not contain the expected + hosts. This can be used to add hostvars from expressions so + that you know what the resultant values of those expressions are.`}


- {t`Sample constructed inventory plugin:`} - - onClick(e, pluginSample)} - exitDelay={copied ? 1500 : 600} - maxWidth="110px" - variant="plain" - onTooltipHidden={() => setCopied(false)} - > - {copied - ? t`Successfully copied to clipboard!` - : t`Copy to clipboard`} - - - } - > - {pluginSample} - + + + + {t`Constructed inventory examples`} + + + + + +
); } +function LimitToIntersectionExample() { + const [copied, setCopied] = React.useState(false); + const clipboardCopyFunc = (event, text) => { + navigator.clipboard.writeText(text.toString()); + }; + + const onClick = (event, text) => { + clipboardCopyFunc(event, text); + setCopied(true); + }; + + const limitToIntersectionLimit = `is_shutdown:&product_dev`; + const limitToIntersectionCode = `plugin: constructed +strict: true +groups: + shutdown_in_product_dev: state | default("running") == "shutdown" and account_alias == "product_dev"`; + + return ( + + } + > + + + {limitToIntersectionLimit} + + + + + onClick(e, limitToIntersectionCode)} + exitDelay={copied ? 1500 : 600} + maxWidth="110px" + variant="plain" + onTooltipHidden={() => setCopied(false)} + > + {copied + ? t`Successfully copied to clipboard!` + : t`Copy to clipboard`} + + + } + > + + {limitToIntersectionCode} + + + + + ); +} +function FilterOnNestedGroupExample() { + const [copied, setCopied] = React.useState(false); + const clipboardCopyFunc = (event, text) => { + navigator.clipboard.writeText(text.toString()); + }; + + const onClick = (event, text) => { + clipboardCopyFunc(event, text); + setCopied(true); + }; + + const nestedGroupsInventoryLimit = `groupA`; + const nestedGroupsInventorySourceVars = `plugin: constructed`; + const nestedGroupsInventory = `all: + children: + groupA: + children: + groupB: + hosts: + host1: {} + vars: + filter_var: filter_val + ungrouped: + hosts: + host2: {}`; + + return ( + + } + > + +

{t`Nested groups inventory definition:`}

+ + + {nestedGroupsInventory} + + +
+ + + {nestedGroupsInventoryLimit} + + + + + onClick(e, nestedGroupsInventorySourceVars)} + exitDelay={copied ? 1500 : 600} + maxWidth="110px" + variant="plain" + onTooltipHidden={() => setCopied(false)} + > + {copied + ? t`Successfully copied to clipboard!` + : t`Copy to clipboard`} + + + } + > + + {nestedGroupsInventorySourceVars} + + + +
+ ); +} +function HostsByProcessorTypeExample() { + const [copied, setCopied] = React.useState(false); + const clipboardCopyFunc = (event, text) => { + navigator.clipboard.writeText(text.toString()); + }; + + const onClick = (event, text) => { + clipboardCopyFunc(event, text); + setCopied(true); + }; + + const hostsByProcessorLimit = `intel_hosts`; + const hostsByProcessorSourceVars = `plugin: constructed + strict: true + groups: + intel_hosts: "GenuineIntel" in ansible_processor`; + + return ( + + } + > + + + {hostsByProcessorLimit} + + + + + onClick(e, hostsByProcessorSourceVars)} + exitDelay={copied ? 1500 : 600} + maxWidth="110px" + variant="plain" + onTooltipHidden={() => setCopied(false)} + > + {copied + ? t`Successfully copied to clipboard!` + : t`Copy to clipboard`} + + + } + > + + {hostsByProcessorSourceVars} + + + + + ); +} + export default ConstructedInventoryHint; diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js index e4745773f4..6132f107a9 100644 --- a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; import '@testing-library/jest-dom'; import ConstructedInventoryHint from './ConstructedInventoryHint'; @@ -10,7 +10,7 @@ describe('', () => { render(); expect( screen.getByRole('link', { - name: 'View constructed plugin documentation here', + name: 'View constructed inventory documentation here', }) ).toBeInTheDocument(); }); @@ -33,14 +33,20 @@ describe('', () => { }); jest.spyOn(navigator.clipboard, 'writeText'); - const { container } = render(); + render(); fireEvent.click(screen.getByRole('button', { name: 'Info alert details' })); fireEvent.click( - container.querySelector('button[aria-label="Copy to clipboard"]') + screen.getByRole('button', { name: 'Hosts by processor type' }) + ); + fireEvent.click( + screen.getByRole('button', { + name: 'Copy to clipboard', + }) ); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining('plugin: constructed') + expect.stringContaining( + 'intel_hosts: "GenuineIntel" in ansible_processor' + ) ); }); }); From 77743ef406369b40a3c03a2e778eac5ef9ea9bd7 Mon Sep 17 00:00:00 2001 From: Rick Elrod Date: Tue, 28 Mar 2023 09:19:01 -0500 Subject: [PATCH 33/33] [collection] Example for constructed inventories (#13755) Signed-off-by: Rick Elrod --- awx_collection/plugins/modules/inventory.py | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/awx_collection/plugins/modules/inventory.py b/awx_collection/plugins/modules/inventory.py index 3a479bcd03..3de24bf1ba 100644 --- a/awx_collection/plugins/modules/inventory.py +++ b/awx_collection/plugins/modules/inventory.py @@ -100,6 +100,35 @@ EXAMPLES = ''' description: "Our Foo Cloud Servers" organization: Foo state: present + +# You can create and modify constructed inventories by creating an inventory +# of kind "constructed" and then editing the automatically generated inventory +# source for that inventory. +- name: Add constructed inventory with two existing input inventories + inventory: + name: My Constructed Inventory + organization: Default + kind: constructed + input_inventories: + - "West Datacenter" + - "East Datacenter" + +- name: Edit the constructed inventory source + inventory_source: + # The constructed inventory source will always be in the format: + # "Auto-created source for: " + name: "Auto-created source for: My Constructed Inventory" + inventory: My Constructed Inventory + limit: host3,host4,host6 + source_vars: + plugin: constructed + strict: true + use_vars_plugins: true + groups: + shutdown: resolved_state == "shutdown" + shutdown_in_product_dev: resolved_state == "shutdown" and account_alias == "product_dev" + compose: + resolved_state: state | default("running") '''