diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 1ff00854c2..4b3a62c841 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',), @@ -191,6 +192,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. @@ -1672,13 +1678,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 +1690,18 @@ 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['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): @@ -1732,6 +1743,91 @@ 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.'), + ) + 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') + CONSTRUCTED_INVENTORY_SOURCE_EDITABLE_FIELDS + read_only_fields = ('*', 'kind') + + def pop_inv_src_data(self, data): + inv_src_data = {} + 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) + # 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): + 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) + 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 = () @@ -1785,6 +1881,9 @@ 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}) + 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: @@ -1806,6 +1905,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', [ @@ -1816,7 +1919,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 @@ -1841,8 +1944,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): @@ -1940,8 +2043,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): @@ -2140,6 +2243,7 @@ class InventorySourceOptionsSerializer(BaseSerializer): 'custom_virtualenv', 'timeout', 'verbosity', + 'limit', ) read_only_fields = ('*', 'custom_virtualenv') @@ -2246,8 +2350,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 @@ -2291,9 +2395,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': + 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: @@ -4035,6 +4146,7 @@ class JobHostSummarySerializer(BaseSerializer): '-description', 'job', 'host', + 'constructed_host', 'host_name', 'changed', 'dark', diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index 7e2fa4ebe2..b83b9b7208 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -6,7 +6,10 @@ from django.urls import re_path from awx.api.views.inventory import ( InventoryList, InventoryDetail, + ConstructedInventoryDetail, + ConstructedInventoryList, InventoryActivityStreamList, + InventoryInputInventoriesList, InventoryJobTemplateList, InventoryAccessList, InventoryObjectRolesList, @@ -37,6 +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]+)/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'), @@ -48,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/__init__.py b/awx/api/views/__init__.py index c1e99a4002..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 _ @@ -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) @@ -1617,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/api/views/inventory.py b/awx/api/views/inventory.py index 72b04d7d8b..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 @@ -31,6 +32,7 @@ from awx.api.views.labels import LabelSubListCreateAttachDetachView from awx.api.serializers import ( InventorySerializer, + ConstructedInventorySerializer, ActivityStreamSerializer, RoleSerializer, InstanceGroupSerializer, @@ -79,7 +81,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 +98,29 @@ 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 InventoryInputInventoriesList(SubListAttachDetachAPIView): + model = Inventory + serializer_class = InventorySerializer + parent_model = Inventory + relationship = 'input_inventories' + + 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): model = ActivityStream serializer_class = ActivityStreamSerializer 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/constants.py b/awx/main/constants.py index 85a14cca4c..32d8a2184c 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 @@ -109,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/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/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 6fa5b59346..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) @@ -654,13 +671,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/migrations/0182_constructed_inventory.py b/awx/main/migrations/0182_constructed_inventory.py new file mode 100644 index 0000000000..54ef7f1328 --- /dev/null +++ b/awx/main/migrations/0182_constructed_inventory.py @@ -0,0 +1,138 @@ +# 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): + dependencies = [ + ('main', '0181_hostmetricsummarymonthly'), + ] + + 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=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', + ), + ), + 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, + ), + ), + 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'), + ), + 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.', + ), + ), + 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/__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/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/inventory.py b/awx/main/models/inventory.py index 829017ee1d..a4ddaabafd 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 @@ -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. @@ -67,6 +77,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 +150,14 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): default=None, help_text=_('Filter that will be applied to the hosts of this inventory.'), ) + 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', blank=True, @@ -187,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') @@ -338,13 +359,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 @@ -431,12 +451,24 @@ 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, overwrite_vars=True, update_on_launch=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 +904,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')), @@ -951,7 +984,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, @@ -971,6 +1004,21 @@ class InventorySourceOptions(BaseModel): blank=True, default=1, ) + limit = models.TextField( + blank=True, + default='', + 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): @@ -1407,6 +1455,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 +1640,18 @@ 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 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 + + for cls in PluginFileInjector.__subclasses__(): InventorySourceOptions.injectors[cls.__name__] = cls diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 61b87f4807..5e55683c20 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,9 @@ 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.db.models.functions import Cast # 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 @@ -28,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, @@ -44,7 +39,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 +55,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'] @@ -578,12 +571,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, @@ -848,109 +836,26 @@ 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 + 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() - @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) + 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: - hosts = self._get_inventory_hosts() + host_qs = self.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']) + 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): @@ -1172,6 +1077,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, diff --git a/awx/main/tasks/facts.py b/awx/main/tasks/facts.py new file mode 100644 index 0000000000..3db5f13091 --- /dev/null +++ b/awx/main/tasks/facts.py @@ -0,0 +1,117 @@ +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 +from awx.main.models.inventory import Host + + +logger = logging.getLogger('awx.main.tasks.facts') +system_tracking_logger = logging.getLogger('awx.analytics.system_tracking') + + +@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(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) + except FileExistsError: + pass + + if timeout is None: + timeout = settings.ANSIBLE_FACT_CACHE_TIMEOUT + + 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))) + 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(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 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=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: + Host.objects.bulk_update(hosts_to_update, ['ansible_facts', 'ansible_facts_modified']) + hosts_to_update = [] + if hosts_to_update: + 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 9da4bc074b..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, @@ -63,6 +64,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 @@ -315,17 +317,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.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) + 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 @@ -450,6 +457,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): @@ -548,7 +558,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: @@ -1003,6 +1014,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 @@ -1068,8 +1082,11 @@ 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.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): self.sync_and_copy(job.project, private_data_dir, scm_branch=job.scm_branch) @@ -1083,10 +1100,14 @@ 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.get_hosts_for_fact_cache(), os.path.join(private_data_dir, 'artifacts', str(job.id), 'fact_cache'), - self.facts_write_time, + facts_write_time=self.facts_write_time, + job_id=job.id, + inventory_id=job.inventory_id, ) def final_run_hook(self, job, status, private_data_dir): @@ -1469,8 +1490,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 +1542,22 @@ 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': + 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.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) container_location = os.path.join(CONTAINER_ROOT, rel_path) @@ -1530,6 +1565,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')) @@ -1545,6 +1585,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/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index 80357a22f9..ab84ff236d 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -594,3 +594,89 @@ 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_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'), + 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/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/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/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] 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..2602cf2947 --- /dev/null +++ b/awx/main/tests/functional/test_inventory_input_constructed.py @@ -0,0 +1,61 @@ +import pytest +from awx.main.models import Inventory +from awx.api.versioning import reverse + + +@pytest.mark.django_db +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': inv1.pk}), + data={'id': inv2.pk}, + user=admin_user, + expect=400, + ) + assert resp.status_code == 400 + + +@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_source_factory): + inv_src = inventory_source_factory(name='dummy1', source='constructed') + resp = patch( + reverse('api:inventory_source_detail', kwargs={'pk': inv_src.pk}), + data={'description': inv_src.name}, + user=admin_user, + expect=400, + ) + assert resp.status_code == 400 diff --git a/awx/main/tests/unit/models/test_jobs.py b/awx/main/tests/unit/models/test_jobs.py index 2f030a57c3..4f05a82535 100644 --- a/awx/main/tests/unit/models/test_jobs.py +++ b/awx/main/tests/unit/models/test_jobs.py @@ -6,37 +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(inventory): +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(): - return Inventory(id=5) - - -@pytest.fixture -def job(mocker, hosts, 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 = job.start_job_fact_cache(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) @@ -46,25 +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): - job._get_inventory_hosts = mocker.Mock( - 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') - job.start_job_fact_cache(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 = job.start_job_fact_cache(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') @@ -80,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)) - job.finish_job_fact_cache(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 = job.start_job_fact_cache(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') @@ -103,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)) - job.finish_job_fact_cache(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 = job.start_job_fact_cache(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)) - job.finish_job_fact_cache(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']) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index f3b6c18eef..e7720e150b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -760,6 +760,13 @@ CUSTOM_EXCLUDE_EMPTY_GROUPS = False SCM_EXCLUDE_EMPTY_GROUPS = False # SCM_INSTANCE_ID_VAR = +# ---------------- +# -- Constructed -- +# ---------------- +CONSTRUCTED_INSTANCE_ID_VAR = 'remote_tower_id' + +CONSTRUCTED_EXCLUDE_EMPTY_GROUPS = False + # --------------------- # -- Activity Stream -- # --------------------- 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/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..d1384e915e --- /dev/null +++ b/awx/ui/src/api/models/ConstructedInventories.js @@ -0,0 +1,10 @@ +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..7e53e161d1 100644 --- a/awx/ui/src/api/models/Inventories.js +++ b/awx/ui/src/api/models/Inventories.js @@ -13,6 +13,9 @@ class Inventories extends InstanceGroupsMixin(Base) { this.readGroups = this.readGroups.bind(this); this.readGroupsOptions = this.readGroupsOptions.bind(this); 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) { @@ -72,6 +75,12 @@ class Inventories extends InstanceGroupsMixin(Base) { }); } + readInputInventories(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, @@ -130,6 +139,19 @@ class Inventories extends InstanceGroupsMixin(Base) { disassociate: true, }); } + + associateInventory(id, inputInventoryId) { + return this.http.post(`${this.baseUrl}${id}/input_inventories/`, { + 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/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/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/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'] ); 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, 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 new file mode 100644 index 0000000000..ff02136101 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.js @@ -0,0 +1,212 @@ +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 InventoryGroups from './InventoryGroups'; +import AdvancedInventoryHosts from './AdvancedInventoryHosts'; +import { getInventoryPath } from './shared/utils'; + +function ConstructedInventory({ setBreadcrumb }) { + const location = useLocation(); + const match = useRouteMatch('/inventories/constructed_inventory/:id'); + + const { + result: inventory, + error: contentError, + request: fetchInventory, + isLoading, + } = useRequest( + useCallback(async () => { + const { data } = await ConstructedInventoriesAPI.readDetail( + match.params.id + ); + return data; + }, [match.params.id]), + { inventory: null, isLoading: true } + ); + + 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 (isLoading) { + 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', 'add', 'groups/', 'hosts/'].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..6519b1c6ca --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventory.test.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { ConstructedInventoriesAPI } from 'api'; +import { + mountWithContexts, + waitForElement, +} 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; + + test('should render expected tabs', async () => { + ConstructedInventoriesAPI.readDetail.mockResolvedValue({ + data: mockInventory, + }); + 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 () => { + ConstructedInventoriesAPI.readDetail.mockResolvedValue({ + data: { ...mockInventory, kind: 'constructed' }, + }); + 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/:inventoryType/:id/foobar', + }, + }, + }, + }, + } + ); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + 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..4263088d5f --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js @@ -0,0 +1,58 @@ +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 ( + + + + + + + + ); +} + +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..f2397064e5 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js @@ -0,0 +1,121 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +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('', () => { + 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(); + }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); + 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/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..6108dc2330 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.js @@ -0,0 +1,371 @@ +import React, { useCallback, useEffect } from 'react'; +import { Link, useHistory } from 'react-router-dom'; + +import { t } from '@lingui/macro'; +import { + Button, + Chip, + Label, + LabelGroup, + TextList, + 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 ChipGroup from 'components/ChipGroup'; +import { VariablesDetail } from 'components/CodeEditor'; +import ContentError from 'components/ContentError'; +import ContentLoading from 'components/ContentLoading'; +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, inputInventories, inventorySource, actions }, + request: fetchRelatedDetails, + error: contentError, + isLoading, + } = useRequest( + useCallback(async () => { + const [ + instanceGroupsResponse, + inputInventoriesResponse, + inventorySourceResponse, + optionsResponse, + ] = await Promise.all([ + InventoriesAPI.readInstanceGroups(inventory.id), + InventoriesAPI.readInputInventories(inventory.id), + InventoriesAPI.readSources(inventory.id), + ConstructedInventoriesAPI.readOptions(), + ]); + + return { + instanceGroups: instanceGroupsResponse.data.results, + inputInventories: inputInventoriesResponse.data.results, + inventorySource: inventorySourceResponse.data.results[0], + actions: optionsResponse.data.actions.GET, + }; + }, [inventory.id]), + { + instanceGroups: [], + inputInventories: [], + inventorySource: {}, + actions: {}, + isLoading: true, + } + ); + + useEffect(() => { + fetchRelatedDetails(); + }, [fetchRelatedDetails]); + + const wsInventorySource = useWsInventorySourcesDetails(inventorySource); + const inventorySourceSyncJob = + 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 () => { + await InventoriesAPI.destroy(inventory.id); + history.push(`/inventories`); + }, [inventory.id, history]) + ); + + const { error, dismissError } = useDismissableError(deleteError); + + const deleteDetailsRequests = + relatedResourceDeleteRequests.inventory(inventory); + + if (isLoading) { + return ; + } + + if (contentError) { + return ; + } + + return ( + + + + + ) + } + /> + + + + + {inventory.summary_fields?.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} + /> + + {inputInventories?.map((inputInventory) => ( + + ))} + + } + isEmpty={inputInventories?.length === 0} + /> + + + + + + {inventory?.summary_fields?.user_capabilities?.edit && ( + + )} + {inventorySource?.summary_fields?.user_capabilities?.start && + (['new', 'running', 'pending', 'waiting'].includes( + inventorySourceSyncJob?.status + ) ? ( + + ) : ( + + ))} + {inventory?.summary_fields?.user_capabilities?.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..888d2b995c --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventoryDetail.test.js @@ -0,0 +1,250 @@ +import React from 'react'; +import { Router } from 'react-router-dom'; +import { InventoriesAPI, ConstructedInventoriesAPI } from 'api'; +import { + render, + screen, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { createMemoryHistory } from 'history'; +import { I18nProvider } from '@lingui/react'; +import { i18n } from '@lingui/core'; +import { en } from 'make-plural/plurals'; +import english from '../../../locales/en/messages'; +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, + }, + labels: { + count: 1, + results: [ + { + id: 17, + name: 'seventeen', + }, + ], + }, + }, + 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: true, + update_cache_timeout: 0, + limit: '', + verbosity: 1, + source_vars: + '{\n "plugin": "constructed",\n "strict": true,\n "groups": {\n "shutdown": "resolved_state == \\"shutdown\\"",\n "shutdown_in_product_dev": "resolved_state == \\"shutdown\\" and account_alias == \\"product_dev\\""\n },\n "compose": {\n "resolved_state": "state | default(\\"running\\")"\n }\n}', +}; + +describe('', () => { + const history = createMemoryHistory({ + initialEntries: ['/inventories/constructed_inventory/1/details'], + }); + + const Component = (props) => ( + + + + + + ); + + beforeEach(() => { + i18n.loadLocaleData({ en: { plurals: en } }); + i18n.load({ en: english }); + i18n.activate('en'); + + InventoriesAPI.readInstanceGroups.mockResolvedValue({ + data: { results: [] }, + }); + InventoriesAPI.readInputInventories.mockResolvedValue({ + data: { + results: [ + { + id: 123, + name: 'input_inventory_123', + }, + { + id: 456, + name: 'input_inventory_456', + }, + ], + }, + }); + InventoriesAPI.readSources.mockResolvedValue({ + data: { + results: [ + { + id: 999, + type: 'inventory_source', + summary_fields: { + last_job: { + id: 101, + name: 'Auto-created source for: Constructed Inv', + status: 'successful', + finished: '2023-02-02T22:22:22.222220Z', + }, + user_capabilities: { + start: true, + }, + }, + }, + ], + }, + }); + ConstructedInventoriesAPI.readOptions.mockResolvedValue({ + data: { + related: {}, + actions: { + GET: { + limit: { + label: 'Limit', + help_text: '', + }, + total_groups: { + label: 'Total Groups', + help_text: '', + }, + total_hosts: { + label: 'Total Hosts', + help_text: '', + }, + total_inventory_sources: { + label: 'Total inventory sources', + help_text: '', + }, + update_cache_timeout: { + label: 'Update cache timeout', + help_text: '', + }, + inventory_sources_with_failures: { + label: 'Inventory sources with failures', + help_text: '', + }, + source_vars: { + label: 'Source vars', + help_text: '', + }, + verbosity: { + label: 'Verbosity', + help_text: '', + }, + created: { + label: 'Created by', + help_text: '', + }, + modified: { + label: 'Modified by', + help_text: '', + }, + }, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('should render details', async () => { + render(); + await waitForElementToBeRemoved(() => screen.getByRole('progressbar')); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Constructed Inv')).toBeInTheDocument(); + expect(screen.getByText('Last Job Status')).toBeInTheDocument(); + expect(screen.getByText('Successful')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Constructed Inventory')).toBeInTheDocument(); + }); + + test('should render action buttons', async () => { + render(); + await waitForElementToBeRemoved(() => screen.getByRole('progressbar')); + expect(screen.getByRole('link', { name: 'Edit' })).toHaveAttribute( + 'href', + '/inventories/constructed_inventory/1/edit' + ); + expect( + screen.getByRole('button', { name: 'Start inventory source sync' }) + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + test('should show cancel sync button during an inventory source sync running job', async () => { + InventoriesAPI.readSources.mockResolvedValue({ + data: { + results: [ + { + id: 999, + type: 'inventory_source', + summary_fields: { + current_job: { + id: 111, + name: 'Auto-created source for: Constructed Inv', + status: 'running', + }, + user_capabilities: { + start: true, + }, + }, + }, + ], + }, + }); + render(); + await waitForElementToBeRemoved(() => screen.getByRole('progressbar')); + expect( + screen.getByRole('button', { + name: 'Cancel Constructed Inventory Source Sync', + }) + ).toBeInTheDocument(); + }); + + test('should show error when the api throws while fetching details', async () => { + InventoriesAPI.readInputInventories.mockRejectedValueOnce(new Error()); + render(); + await waitForElementToBeRemoved(() => screen.getByRole('progressbar')); + expect( + screen.getByText( + 'There was an error loading this content. Please reload the page.' + ) + ).toBeInTheDocument(); + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.js b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.js new file mode 100644 index 0000000000..ac030cf1d5 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryDetail/ConstructedInventorySyncButton.js @@ -0,0 +1,59 @@ +import React, { useCallback } from 'react'; +import { t } from '@lingui/macro'; +import PropTypes from 'prop-types'; +import { Button, Tooltip } from '@patternfly/react-core'; +import useRequest, { useDismissableError } from 'hooks/useRequest'; +import AlertModal from 'components/AlertModal/AlertModal'; +import ErrorDetail from 'components/ErrorDetail/ErrorDetail'; +import { InventoriesAPI } from 'api'; + +function ConstructedInventorySyncButton({ inventoryId }) { + const testId = `constructed-inventory-${inventoryId}-sync`; + const { + isLoading: startSyncLoading, + error: startSyncError, + request: startSyncProcess, + } = useRequest( + useCallback( + async () => InventoriesAPI.syncAllSources(inventoryId), + [inventoryId] + ), + {} + ); + + const { error: startError, dismissError: dismissStartError } = + useDismissableError(startSyncError); + + return ( + <> + + + + {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/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..bb87534379 --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js @@ -0,0 +1,124 @@ +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 ; + } + + return ( + + + + ); +} + +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..ee52a8ca1b --- /dev/null +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js @@ -0,0 +1,196 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; + +import { + mountWithContexts, + waitForElement, +} from '../../../../testUtils/enzymeHelpers'; +import { ConstructedInventoriesAPI, InventoriesAPI } from 'api'; + +import ConstructedInventoryEdit from './ConstructedInventoryEdit'; +jest.mock('api'); + +describe('', () => { + 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: '', + }, + }, + }, + }, + }); + 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/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/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/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/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..b0a8bdfc38 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/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..3dad28809f 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_groups', + ], + }); + 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() { 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/InventorySources/useWsInventorySourcesDetails.js deleted file mode 100644 index e93f28f58b..0000000000 --- a/awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.js +++ /dev/null @@ -1,42 +0,0 @@ -import { useState, useEffect } from 'react'; -import useWebsocket from 'hooks/useWebsocket'; - -export default function useWsInventorySourcesDetails(initialSources) { - const [sources, setSources] = useState(initialSources); - const lastMessage = useWebsocket({ - jobs: ['status_changed'], - control: ['limit_reached_1'], - }); - - useEffect(() => { - setSources(initialSources); - }, [initialSources]); - - useEffect( - () => { - if ( - !lastMessage?.unified_job_id || - !lastMessage?.inventory_source_id || - lastMessage.type !== 'inventory_update' - ) { - return; - } - const updateSource = { - ...sources, - summary_fields: { - ...sources.summary_fields, - current_job: { - id: lastMessage.unified_job_id, - status: lastMessage.status, - finished: lastMessage.finished, - }, - }, - }; - - setSources(updateSource); - }, - [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps - ); - - return sources; -} diff --git a/awx/ui/src/screens/Inventory/SmartInventory.js b/awx/ui/src/screens/Inventory/SmartInventory.js index 952cf5dc31..d55327aa27 100644 --- a/awx/ui/src/screens/Inventory/SmartInventory.js +++ b/awx/ui/src/screens/Inventory/SmartInventory.js @@ -22,7 +22,8 @@ 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 }) { const location = useLocation(); @@ -101,8 +102,8 @@ function SmartInventory({ setBreadcrumb }) { ); } - if (inventory?.kind === '') { - return ; + if (inventory && inventory?.kind !== 'smart') { + return ; } let showCardHeader = true; @@ -141,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'; 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..470ab61366 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js @@ -0,0 +1,236 @@ +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({ + constructedInventory, + instanceGroups, + inputInventories, + onCancel, + onSubmit, + submitError, +}) { + const initialValues = { + kind: 'constructed', + 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 { + 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) => ( +
+ + + {submitError && } + + +
+ )} +
+ ); +} + +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..be22d4ba13 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.js @@ -0,0 +1,359 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { + Alert, + AlertActionLink, + ClipboardCopyButton, + CodeBlock, + CodeBlockAction, + CodeBlockCode, + ClipboardCopy, + Form, + FormFieldGroupExpandable, + FormFieldGroupHeader, + FormGroup, + Panel, + CardBody, +} 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(); + + return ( + + {t`View constructed inventory documentation here`}{' '} + + + } + > + + {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`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.`} + + + + + groups +

{t`dictionary`}

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

{t`dictionary`}

+ + + {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`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 new file mode 100644 index 0000000000..6132f107a9 --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryHint.test.js @@ -0,0 +1,52 @@ +import React from 'react'; +import { render, screen, fireEvent, within } 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 inventory 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'); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Info alert details' })); + fireEvent.click( + screen.getByRole('button', { name: 'Hosts by processor type' }) + ); + fireEvent.click( + screen.getByRole('button', { + name: 'Copy to clipboard', + }) + ); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining( + 'intel_hosts: "GenuineIntel" in ansible_processor' + ) + ); + }); +}); 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.`, diff --git a/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js new file mode 100644 index 0000000000..e010b8916a --- /dev/null +++ b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.js @@ -0,0 +1,58 @@ +import { useState, useEffect } from 'react'; +import useWebsocket from 'hooks/useWebsocket'; +import { InventorySourcesAPI } from 'api'; + +export default function useWsInventorySourcesDetails(initialSource) { + const [source, setSource] = useState(initialSource); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); + + useEffect(() => { + setSource(initialSource); + }, [initialSource]); + + useEffect( + () => { + if ( + !lastMessage?.unified_job_id || + !lastMessage?.inventory_source_id || + lastMessage.type !== 'inventory_update' + ) { + return; + } + + if ( + ['successful', 'failed', 'error', 'cancelled'].includes( + lastMessage.status + ) + ) { + fetchSource(); + } + setSource(updateSource(source, lastMessage)); + }, + [lastMessage] // eslint-disable-line react-hooks/exhaustive-deps + ); + + 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/InventorySources/useWsInventorySourcesDetails.test.js b/awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js similarity index 81% rename from awx/ui/src/screens/Inventory/InventorySources/useWsInventorySourcesDetails.test.js rename to awx/ui/src/screens/Inventory/shared/useWsInventorySourcesDetails.test.js index 25fb97850b..d1f1e17009 100644 --- a/awx/ui/src/screens/Inventory/InventorySources/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(); }); }); diff --git a/awx/ui/src/screens/Inventory/shared/utils.js b/awx/ui/src/screens/Inventory/shared/utils.js index c08710327f..121d08f198 100644 --- a/awx/ui/src/screens/Inventory/shared/utils.js +++ b/awx/ui/src/screens/Inventory/shared/utils.js @@ -8,3 +8,13 @@ const parseHostFilter = (value) => { return value; }; export default parseHostFilter; + +export function getInventoryPath(inventory) { + if (!inventory) return '/inventories'; + 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' + ); + }); +}); diff --git a/awx_collection/plugins/modules/inventory.py b/awx_collection/plugins/modules/inventory.py index 8e739b2211..3de24bf1ba 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 @@ -95,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") ''' @@ -111,11 +145,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 +216,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/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 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 = [ 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/' 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.