Compare commits

...

54 Commits

Author SHA1 Message Date
Alex Corey
e985b98d61 Adds constructed inventory groups and related groups. 2023-02-23 14:03:58 -05:00
Alex Corey
0fae313338 Merge pull request #13590 from ansible/constructedInventoryHosts
Constructed inventory hosts
2023-02-22 11:07:25 -05:00
Marliana Lara
f1cab55051 Merge pull request #13598 from marshmalien/constructed-inventory-sync-button
Add constructed inventory detail sync button
2023-02-22 10:20:45 -05:00
Alex Corey
0d88cee6bf Creates constructed inventory host lists by reusing, and renaming smart inventory host list components. 2023-02-22 10:01:31 -05:00
Marliana Lara
295ec4f22a Update inventory details after inventory source sync 2023-02-21 17:47:56 -05:00
Hao Liu
103b4567fe Merge pull request #13600 from gamuniz/fix_validation_bug_constructed
[constructed-inventory]Fix validation issue constructed
2023-02-20 10:33:21 -05:00
Gabe Muniz
8ba2b1b50c [constructed-inventory]Fix validation issue constructed 2023-02-20 10:20:23 -05:00
Marliana Lara
83ccf1dd36 Add constructed inventory detail's sync button 2023-02-18 14:51:01 -05:00
Marliana Lara
dc049af0eb Merge pull request #13578 from marshmalien/constructed-inv-search-option
[constructed-inventory] Add "constructed" option to search columns
2023-02-16 13:05:22 -05:00
Marliana Lara
8ea8558605 Add constructed inv option to search columns 2023-02-15 17:21:23 -05:00
Hao Liu
3335afcd3a Merge branch 'devel' into feature_constructed-inventory 2023-02-15 10:05:02 -05:00
Gabriel Muniz
1f2a5cf7e4 Merge pull request #13555 from gamuniz/deny_adding_constructedinventory
[constructed-inventory] remove ability to add constructed inventories to input inventories
2023-02-15 10:03:58 -05:00
Gabe Muniz
7f2933c43c moved fixture to conftest.py and reverted some test changes 2023-02-14 17:54:54 -05:00
Gabe Muniz
d55af032f7 refactored to use is_valid_relation instead of post 2023-02-14 00:08:21 -05:00
Gabe Muniz
3ff65db2e6 block updates to constructed source type 2023-02-12 13:13:41 -05:00
Gabe Muniz
e25c767a47 added tests for group/host/source creation 2023-02-11 10:57:47 -05:00
Gabriel Muniz
0866bfc549 Merge pull request #13552 from gamuniz/prevent_constructed_sourceinventory
[constructed-inventory] Prevent constructed sourceinventory creation
2023-02-10 15:32:53 -05:00
Gabe Muniz
2d9f2d36a1 remove ability to add constructed inventories to constructed inventories 2023-02-10 14:56:25 -05:00
Gabe Muniz
c60ba5cec9 remove the ability to add constructed inventory sources 2023-02-10 10:18:24 -05:00
Gabriel Muniz
c98f86a355 Merge pull request #13547 from gamuniz/disable_host_creation_constructed
[constructed-inventory] Disable host creation constructed
2023-02-09 15:18:23 -05:00
Gabe Muniz
3f0d28dd7f disable deleting constructed inventory hosts 2023-02-09 13:10:29 -05:00
Gabe Muniz
49e5d76062 prevent constructed inventory host,group,inventory_source creation 2023-02-09 12:56:33 -05:00
Hao Liu
ecd788312e Merge pull request #13537 from shanemcd/input-inventories
Update UI to use new input_inventories endpoint
2023-02-08 11:09:14 -05:00
Shane McDonald
e1e27a028c Update UI to use new input_inventories endpoint 2023-02-08 10:55:36 -05:00
Hao Liu
0961ca06c9 Merge pull request #13514 from marshmalien/constructed-inv-routes
[constructed-inventory] Add details view and skeleton routes
2023-02-06 16:50:41 -05:00
Marliana Lara
a3d7c02802 Add constructed inventory CRUD and subtab routes
* Add constructed inventory API model
 * Add constructed inventory detail view
 * Add util to switch inventory url based on "kind"
2023-02-06 14:55:55 -05:00
Hao Liu
280ceae267 Merge pull request #13525 from TheRealHaoLiu/inventory-module-update
[constructed-inventory] add 'constructed' kind to inventory module for awx-collection
2023-02-06 14:40:12 -05:00
Hao Liu
ea719e053e ignore lack of constructed_inventory module 2023-02-06 14:28:52 -05:00
Hao Liu
f275c2a9c5 add constructed kind to inventory module
- add kind 'constructed' to inventory module
- add 'input_inventories' field to inventory module
2023-02-06 14:28:52 -05:00
Hao Liu
3242dbcbe6 Merge branch 'devel' into feature_constructed-inventory 2023-02-06 10:09:47 -05:00
Gabriel Muniz
341f8e385c Merge pull request #13520 from gamuniz/constructed_inventory_host_linkage
[constructed-inventory] Constructed inventory host linkage
2023-02-03 12:09:43 -05:00
Gabe Muniz
659853dcea missed a file on linting 2023-02-03 10:18:38 -05:00
Gabe Muniz
80c15e286f refactored original hosts and renamed source_inventories to input_inventories 2023-02-03 10:04:58 -05:00
Gabe Muniz
c22d8f1d7e adding a related field to point back to the original host 2023-02-02 21:51:37 -05:00
Gabe Muniz
27a97017dd initial start with notes from alan 2023-02-02 17:09:59 -05:00
Hao Liu
c72dca3ea5 Merge branch 'devel' into feature_constructed-inventory 2023-02-02 13:49:54 -05:00
Gabriel Muniz
ddb3cde872 Merge pull request #13501 from gamuniz/readonly_kind_constructed
force kind to readonly field and set kind to constructed in create
2023-02-02 13:49:18 -05:00
Gabe Muniz
c2ec8396cd force kind to readonly field and set kind to constructed in create 2023-02-01 14:22:00 -05:00
Hao Liu
de115ed1c8 Merge branch 'devel' into feature_constructed-inventory 2023-01-31 16:42:11 -05:00
Alan Rominger
87918bd275 [constructed-inventory] Add constructed inventory docs and do minor field updates (#13487)
* Add constructed inventory docs and do minor field updates

Add verbosity field to the constructed views

automatically set update_on_launch for the auto-created constructed inventory source
2023-01-30 15:12:40 -05:00
Alan Rominger
7598e117d4 [constructed-inventory] Use control plane EE for constructed inventory and hack temporary image (#13474)
* Use control plane EE for constructed inventory and hack temporary image

* Update page registry to work with new endpoints
2023-01-27 13:06:55 -05:00
Gabriel Muniz
700055801a Merge pull request #13470 from gamuniz/collection_limit_addition
[constructed-inventory] adding limit to inventory_source collection module
2023-01-26 09:46:54 -05:00
Gabe Muniz
1c6a48ffb6 adding limit to inventory_source collection module 2023-01-25 10:13:45 -05:00
Alan Rominger
fab83715e9 Merge pull request #13463 from AlanCoding/constructed_view
[constructed-inventory] Add views and serializers for special constructed inventory endpoints
2023-01-25 10:04:56 -05:00
Alan Rominger
0ebe57cbf4 Start on new constructed inventory API view
Make the GET function work at most basic level

Basic functionality of updating working

Add functional test for the GET and PATCH views

Add constructed inventory list view for direct creation

Add limit field to constructed inventory serializer
2023-01-24 11:35:29 -05:00
Hao Liu
d4840b240b move limit field from InventorySourceSerializer to InventorySourceOptionsSerializer (#13464)
InventorySourceOptionsSerializer is the parent for both InventorySourceSerializer and InventoryUpdateSerializer

The limit option need to be exposed to both inventory_source and inventory_update
2023-01-24 11:24:50 -05:00
Gabriel Muniz
8538d37702 Merge pull request #13456 from gamuniz/add_limit_to_serializer
added 'limit' to InventorySourceSerializer
2023-01-20 16:21:58 -05:00
Gabe Muniz
5550086b3b added 'limit' to InventorySourceSerializer 2023-01-20 12:41:35 -05:00
Gabriel Muniz
980bfc4b6f Merge pull request #13449 from gamuniz/inventory_source_limit
[constructed-inventory] pushing limit to inventory sources
2023-01-19 15:01:33 -05:00
Gabe Muniz
6351e8bbc9 added new migration for deprecation of host_filter 2023-01-19 13:01:45 -05:00
Gabe Muniz
325e566a3d pushing limit to inventory sources 2023-01-19 12:40:14 -05:00
Alan Rominger
d7f87ed27c Merge pull request #13303 from AlanCoding/smart_inventory_v2
[constructed-inventory] Constructed inventory as alternative to smart inventory
2023-01-18 22:18:58 -05:00
Alan Rominger
a5baee1b3a Remove extra unwanted assignment from line
Co-authored-by: Rick Elrod <rick@elrod.me>
2023-01-18 10:38:12 -05:00
Alan Rominger
dd8c9f87a9 Model and task changes for constructed inventory
Add in required setting about empty groups
2023-01-18 10:26:56 -05:00
84 changed files with 2710 additions and 446 deletions

View File

@@ -1680,13 +1680,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}),
@@ -1697,8 +1692,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):
@@ -1740,6 +1745,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', 'source_vars', 'update_cache_timeout', 'limit', 'verbosity')
read_only_fields = ('*', 'kind')
def pop_inv_src_data(self, data):
inv_src_data = {}
for field in ('source_vars', 'update_cache_timeout', 'limit', 'verbosity'):
if field in data:
# values always need to be removed, as they are not valid for Inventory model
value = data.pop(field)
# 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 = ()
@@ -1793,6 +1883,8 @@ class HostSerializer(BaseSerializerWithVariables):
ansible_facts=self.reverse('api:host_ansible_facts_detail', kwargs={'pk': obj.pk}),
)
)
if obj.inventory.kind == 'constructed':
res['original_host'] = self.reverse('api:host_detail', kwargs={'pk': obj.instance_id})
if obj.inventory:
res['inventory'] = self.reverse('api:inventory_detail', kwargs={'pk': obj.inventory.pk})
if obj.last_job:
@@ -1849,8 +1941,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):
@@ -1948,8 +2040,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):
@@ -2023,6 +2115,7 @@ class InventorySourceOptionsSerializer(BaseSerializer):
'custom_virtualenv',
'timeout',
'verbosity',
'limit',
)
read_only_fields = ('*', 'custom_virtualenv')
@@ -2129,8 +2222,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
@@ -2151,6 +2244,8 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
obj = super(InventorySourceSerializer, self).update(obj, validated_data)
if deprecated_fields:
self._update_deprecated_fields(deprecated_fields, obj)
if obj.source == 'constructed':
raise serializers.ValidationError({'error': _("Cannot edit source of type constructed.")})
return obj
# TODO: remove when old 'credential' fields are removed
@@ -2177,6 +2272,8 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
if get_field_from_model_or_attrs('source') == 'scm':
if ('source' in attrs or 'source_project' in attrs) and get_field_from_model_or_attrs('source_project') is None:
raise serializers.ValidationError({"source_project": _("Project required for scm type sources.")})
elif (get_field_from_model_or_attrs('source') == 'constructed') and (self.instance and self.instance.source != 'constructed'):
raise serializers.ValidationError({"Error": _('constructed not a valid source for inventory')})
else:
redundant_scm_fields = list(filter(lambda x: attrs.get(x, None), ['source_project', 'source_path']))
if redundant_scm_fields:

View File

@@ -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<pk>[0-9]+)/script/$', InventoryScriptView.as_view(), name='inventory_script_view'),
re_path(r'^(?P<pk>[0-9]+)/tree/$', InventoryTreeView.as_view(), name='inventory_tree_view'),
re_path(r'^(?P<pk>[0-9]+)/inventory_sources/$', InventoryInventorySourcesList.as_view(), name='inventory_inventory_sources_list'),
re_path(r'^(?P<pk>[0-9]+)/input_inventories/$', InventoryInputInventoriesList.as_view(), name='inventory_input_inventories'),
re_path(r'^(?P<pk>[0-9]+)/update_inventory_sources/$', InventoryInventorySourcesUpdate.as_view(), name='inventory_inventory_sources_update'),
re_path(r'^(?P<pk>[0-9]+)/activity_stream/$', InventoryActivityStreamList.as_view(), name='inventory_activity_stream_list'),
re_path(r'^(?P<pk>[0-9]+)/job_templates/$', InventoryJobTemplateList.as_view(), name='inventory_job_template_list'),
@@ -48,4 +52,10 @@ urls = [
re_path(r'^(?P<pk>[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<pk>[0-9]+)/$', ConstructedInventoryDetail.as_view(), name='constructed_inventory_detail'),
]
__all__ = ['urls', 'constructed_inventory_urls']

View File

@@ -39,7 +39,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
@@ -110,6 +110,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'^groups/', include(group_urls)),
re_path(r'^inventory_sources/', include(inventory_source_urls)),

View File

@@ -1559,6 +1559,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)

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,109 @@
# Generated by Django 3.2.16 on 2022-12-07 14:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0174_ensure_org_ee_admin_roles'),
]
operations = [
migrations.AddField(
model_name='inventory',
name='input_inventories',
field=models.ManyToManyField(
blank=True,
help_text='Only valid for constructed inventories, this links to the inventories that will be used.',
related_name='destination_inventories',
to='main.Inventory',
),
),
migrations.AlterField(
model_name='inventory',
name='kind',
field=models.CharField(
blank=True,
choices=[
('', 'Hosts have a direct link to this inventory.'),
('smart', 'Hosts for inventory generated using the host_filter property.'),
('constructed', 'Parse list of source inventories with the constructed inventory plugin.'),
],
default='',
help_text='Kind of inventory being represented.',
max_length=32,
),
),
migrations.AlterField(
model_name='inventorysource',
name='source',
field=models.CharField(
choices=[
('file', 'File, Directory or Script'),
('constructed', 'Template additional groups and hostvars at runtime'),
('scm', 'Sourced from a Project'),
('ec2', 'Amazon EC2'),
('gce', 'Google Compute Engine'),
('azure_rm', 'Microsoft Azure Resource Manager'),
('vmware', 'VMware vCenter'),
('satellite6', 'Red Hat Satellite 6'),
('openstack', 'OpenStack'),
('rhv', 'Red Hat Virtualization'),
('controller', 'Red Hat Ansible Automation Platform'),
('insights', 'Red Hat Insights'),
],
default=None,
max_length=32,
),
),
migrations.AlterField(
model_name='inventoryupdate',
name='source',
field=models.CharField(
choices=[
('file', 'File, Directory or Script'),
('constructed', 'Template additional groups and hostvars at runtime'),
('scm', 'Sourced from a Project'),
('ec2', 'Amazon EC2'),
('gce', 'Google Compute Engine'),
('azure_rm', 'Microsoft Azure Resource Manager'),
('vmware', 'VMware vCenter'),
('satellite6', 'Red Hat Satellite 6'),
('openstack', 'OpenStack'),
('rhv', 'Red Hat Virtualization'),
('controller', 'Red Hat Ansible Automation Platform'),
('insights', 'Red Hat Insights'),
],
default=None,
max_length=32,
),
),
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.',
),
),
]

View File

@@ -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
@@ -67,6 +67,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
KIND_CHOICES = [
('', _('Hosts have a direct link to this inventory.')),
('smart', _('Hosts for inventory generated using the host_filter property.')),
('constructed', _('Parse list of source inventories with the constructed inventory plugin.')),
]
class Meta:
@@ -139,6 +140,12 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
default=None,
help_text=_('Filter that will be applied to the hosts of this inventory.'),
)
input_inventories = models.ManyToManyField(
'Inventory',
blank=True,
related_name='destination_inventories',
help_text=_('Only valid for constructed inventories, this links to the inventories that will be used.'),
)
instance_groups = OrderedManyToManyField(
'InstanceGroup',
blank=True,
@@ -431,12 +438,22 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
connection.on_commit(on_commit)
def _enforce_constructed_source(self):
"""
Constructed inventory should always have exactly 1 inventory source, constructed type
this enforces that requirement
"""
if self.kind == 'constructed':
if not self.inventory_sources.exists():
self.inventory_sources.create(source='constructed', name=f'Auto-created source for: {self.name}'[:512], overwrite=True, 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()
@@ -834,6 +851,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')),
@@ -907,7 +925,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,
@@ -927,6 +945,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):
@@ -1363,6 +1396,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):
@@ -1546,5 +1581,17 @@ class insights(PluginFileInjector):
use_fqcn = True
class constructed(PluginFileInjector):
plugin_name = 'constructed'
namespace = 'ansible'
collection = 'builtin'
def build_env(self, *args, **kwargs):
env = super().build_env(*args, **kwargs)
# Enable all types of inventory plugins so we pick up the script files from source inventories
del env['ANSIBLE_INVENTORY_ENABLED']
return env
for cls in PluginFileInjector.__subclasses__():
InventorySourceOptions.injectors[cls.__name__] = cls

View File

@@ -315,17 +315,22 @@ class BaseTask(object):
return env
def write_inventory_file(self, inventory, private_data_dir, file_name, script_params):
script_data = inventory.get_script_data(**script_params)
for hostname, hv in script_data.get('_meta', {}).get('hostvars', {}).items():
# maintain a list of host_name --> host_id
# so we can associate emitted events to Host objects
self.runner_callback.host_map[hostname] = hv.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
@@ -1464,8 +1469,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:
@@ -1518,6 +1521,15 @@ class RunInventoryUpdate(SourceControlMixin, BaseTask):
args = ['ansible-inventory', '--list', '--export']
# special case for constructed inventories, we pass source inventories from database
# these must come in order, and in order _before_ the constructed inventory itself
if inventory_update.inventory.kind == 'constructed':
for 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))
# 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)
@@ -1525,6 +1537,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'))

View File

@@ -594,3 +594,45 @@ class TestControlledBySCM:
rando,
expect=403,
)
@pytest.mark.django_db
class TestConstructedInventory:
@pytest.fixture
def constructed_inventory(self, organization):
return Inventory.objects.create(name='constructed-test-inventory', kind='constructed', organization=organization)
def test_get_constructed_inventory(self, constructed_inventory, admin_user, get):
inv_src = constructed_inventory.inventory_sources.first()
inv_src.update_cache_timeout = 53
inv_src.save(update_fields=['update_cache_timeout'])
r = get(url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), user=admin_user, expect=200)
assert r.data['update_cache_timeout'] == 53
def test_patch_constructed_inventory(self, constructed_inventory, admin_user, patch):
inv_src = constructed_inventory.inventory_sources.first()
assert inv_src.update_cache_timeout == 0
assert inv_src.limit == ''
r = patch(
url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}),
data=dict(update_cache_timeout=54, limit='foobar'),
user=admin_user,
expect=200,
)
assert r.data['update_cache_timeout'] == 54
inv_src = constructed_inventory.inventory_sources.first()
assert inv_src.update_cache_timeout == 54
assert inv_src.limit == 'foobar'
def test_create_constructed_inventory(self, constructed_inventory, admin_user, post, organization):
r = post(
url=reverse('api:constructed_inventory_list'),
data=dict(name='constructed-inventory-just-created', kind='constructed', organization=organization.id, update_cache_timeout=55, limit='foobar'),
user=admin_user,
expect=201,
)
pk = r.data['id']
constructed_inventory = Inventory.objects.get(pk=pk)
inv_src = constructed_inventory.inventory_sources.first()
assert inv_src.update_cache_timeout == 55
assert inv_src.limit == 'foobar'

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -172,7 +172,9 @@ GLOBAL_JOB_EXECUTION_ENVIRONMENTS = [{'name': 'AWX EE (latest)', 'image': 'quay.
# This image is distinguished from others by having "managed" set to True and users have limited
# ability to modify it through the API.
# If a registry credential is needed to pull the image, that can be provided to the awx-manage command
CONTROL_PLANE_EXECUTION_ENVIRONMENT = 'quay.io/ansible/awx-ee:latest'
# HACK: this is done temporarily for feature development, remove before merge
CONTROL_PLANE_EXECUTION_ENVIRONMENT = 'quay.io/relrod/awx-ee-invlimit:latest'
# Note: This setting may be overridden by database settings.
STDOUT_MAX_BYTES_DISPLAY = 1048576
@@ -742,6 +744,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 --
# ---------------------

View File

@@ -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';
@@ -53,6 +54,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();
@@ -101,6 +103,7 @@ export {
CredentialInputSourcesAPI,
CredentialTypesAPI,
CredentialsAPI,
ConstructedInventoriesAPI,
DashboardAPI,
ExecutionEnvironmentsAPI,
GroupsAPI,

View File

@@ -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;

View File

@@ -13,6 +13,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
this.readGroups = this.readGroups.bind(this);
this.readGroupsOptions = this.readGroupsOptions.bind(this);
this.promoteGroup = this.promoteGroup.bind(this);
this.readInputInventories = this.readInputInventories.bind(this);
}
readAccessList(id, params) {
@@ -72,6 +73,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,

View File

@@ -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 <ContentError error={error} />;
}
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 && (
<Switch>
<Redirect
from="/inventories/smart_inventory/:id/hosts/:hostId"
from="/inventories/:inventoryType/:id/hosts/:hostId"
to={`${path}/details`}
exact
/>
<Route key="details" path={`${path}/details`}>
<SmartInventoryHostDetail host={host} />
<AdvancedInventoryHostDetail host={host} />
</Route>
<Route key="not-found" path="*">
<ContentError isNotFound>
<Link to={`${url}/details`}>
{t`View smart inventory host details`}
{params.inventoryType === 'smart_inventory'
? t`View smart inventory host details`
: t`View constructed inventory host details`}
</Link>
</ContentError>
</Route>
@@ -92,4 +93,4 @@ function SmartInventoryHost({ inventory, setBreadcrumb }) {
);
}
export default SmartInventoryHost;
export default AdvancedInventoryHost;

View File

@@ -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('<SmartInventoryHost />', () => {
describe('<AdvancedInventoryHost />', () => {
let wrapper;
let history;
@@ -36,7 +36,7 @@ describe('<SmartInventoryHost />', () => {
InventoriesAPI.readHostDetail.mockResolvedValue(mockHost);
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHost
<AdvancedInventoryHost
inventory={mockSmartInventory}
setBreadcrumb={() => {}}
/>
@@ -55,7 +55,7 @@ describe('<SmartInventoryHost />', () => {
InventoriesAPI.readHostDetail.mockRejectedValueOnce(new Error());
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHost
<AdvancedInventoryHost
inventory={mockSmartInventory}
setBreadcrumb={() => {}}
/>
@@ -76,7 +76,7 @@ describe('<SmartInventoryHost />', () => {
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHost
<AdvancedInventoryHost
inventory={mockSmartInventory}
setBreadcrumb={() => {}}
/>,

View File

@@ -0,0 +1 @@
export { default } from './AdvancedInventoryHost';

View File

@@ -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 (
<CardBody>
<DetailList gutter="sm">
@@ -37,7 +39,7 @@ function SmartInventoryHostDetail({ host }) {
<Detail
label={t`Inventory`}
value={
<Link to={`/inventories/inventory/${inventory?.id}/details`}>
<Link to={`/inventories/${inventoryKind}/${inventory?.id}/details`}>
{inventory?.name}
</Link>
}
@@ -61,8 +63,8 @@ function SmartInventoryHostDetail({ host }) {
);
}
SmartInventoryHostDetail.propTypes = {
AdvancedInventoryHostDetail.propTypes = {
host: Host.isRequired,
};
export default SmartInventoryHostDetail;
export default AdvancedInventoryHostDetail;

View File

@@ -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('<SmartInventoryHostDetail />', () => {
describe('<AdvancedInventoryHostDetail />', () => {
let wrapper;
beforeAll(() => {
wrapper = mountWithContexts(<SmartInventoryHostDetail host={mockHost} />);
wrapper = mountWithContexts(
<AdvancedInventoryHostDetail host={mockHost} />
);
});
test('should render Details', () => {
@@ -30,11 +32,12 @@ describe('<SmartInventoryHostDetail />', () => {
test('should not load Activity', () => {
wrapper = mountWithContexts(
<SmartInventoryHostDetail
<AdvancedInventoryHostDetail
host={{
...mockHost,
summary_fields: {
recent_jobs: [],
inventory: { kind: 'constructed', id: 2 },
},
}}
/>

View File

@@ -0,0 +1 @@
export { default } from './AdvancedInventoryHostDetail';

View File

@@ -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 (
<PaginatedTable
contentError={contentError}
@@ -114,10 +117,11 @@ function SmartInventoryHostList({ inventory }) {
</HeaderRow>
}
renderRow={(host, index) => (
<SmartInventoryHostListItem
<AdvancedInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
inventoryType={inventoryType}
detailUrl={`/inventories/${inventoryType}/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some((row) => 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;

View File

@@ -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('<SmartInventoryHostList />', () => {
describe('<AdvancedInventoryHostList />', () => {
let wrapper;
const clonedInventory = {
...mockInventory,
@@ -44,7 +44,7 @@ describe('<SmartInventoryHostList />', () => {
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
<AdvancedInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
@@ -55,12 +55,12 @@ describe('<SmartInventoryHostList />', () => {
});
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('<SmartInventoryHostList />', () => {
);
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={mockInventory} />
<AdvancedInventoryHostList inventory={mockInventory} />
);
});
await waitForElement(wrapper, 'ContentError', (el) => el.length === 1);

View File

@@ -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 (
<Tr id={`host-row-${host.id}`} ouiaId={`host-row-${host.id}`}>
<Tr id={`host-row-${id}`} ouiaId={`host-row-${id}`}>
<Td
select={{
rowIndex,
@@ -32,28 +38,24 @@ function SmartInventoryHostListItem({
/>
<Td dataLabel={t`Name`}>
<Link to={`${detailUrl}`}>
<b>{host.name}</b>
<b>{name}</b>
</Link>
</Td>
<Td dataLabel={t`Recent jobs`}>
<Sparkline jobs={recentPlaybookJobs} />
</Td>
<Td dataLabel={t`Inventory`}>
<Link
to={`/inventories/inventory/${host.summary_fields.inventory.id}/details`}
>
{host.summary_fields.inventory.name}
</Link>
<Link to={inventoryLink}>{inventory.name}</Link>
</Td>
</Tr>
);
}
SmartInventoryHostListItem.propTypes = {
AdvancedInventoryHostListItem.propTypes = {
detailUrl: string.isRequired,
host: Host.isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};
export default SmartInventoryHostListItem;
export default AdvancedInventoryHostListItem;

View File

@@ -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('<SmartInventoryHostListItem />', () => {
describe('<AdvancedInventoryHostListItem />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<table>
<tbody>
<SmartInventoryHostListItem
<AdvancedInventoryHostListItem
detailUrl="/inventories/smart_inventory/1/hosts/2"
host={mockHost}
isSelected={false}

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Inventory } from 'types';
import AdvancedInventoryHostList from './AdvancedInventoryHostList';
import AdvancedInventoryHost from '../AdvancedInventoryHost';
function AdvancedInventoryHosts({ inventory, setBreadcrumb }) {
return (
<Switch>
<Route key="host" path="/inventories/:inventoryType/:id/hosts/:hostId">
<AdvancedInventoryHost
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
</Route>
<Route key="host-list" path="/inventories/:inventoryType/:id/hosts">
<AdvancedInventoryHostList inventory={inventory} />
</Route>
</Switch>
);
}
AdvancedInventoryHosts.propTypes = {
inventory: Inventory.isRequired,
};
export default AdvancedInventoryHosts;

View File

@@ -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 = () => <div />;
jest.mock('./AdvancedInventoryHostList', () => {
const AdvancedInventoryHostList = () => <div />;
return {
__esModule: true,
default: SmartInventoryHostList,
default: AdvancedInventoryHostList,
};
});
describe('<SmartInventoryHosts />', () => {
describe('<AdvancedInventoryHosts />', () => {
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(
<SmartInventoryHosts inventory={{ id: 1 }} />,
<AdvancedInventoryHosts inventory={{ id: 1 }} />,
{
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('<SmartInventoryHosts />', () => {
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(
<SmartInventoryHosts inventory={{ id: 1 }} setBreadcrumb={() => {}} />,
<AdvancedInventoryHosts
inventory={{ id: 1 }}
setBreadcrumb={() => {}}
/>,
{
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();
});
});

View File

@@ -0,0 +1 @@
export { default } from './AdvancedInventoryHosts';

View File

@@ -0,0 +1,215 @@
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,
isLoading: hasContentLoading,
request: fetchInventory,
} = useRequest(
useCallback(async () => {
const { data } = await ConstructedInventoriesAPI.readDetail(
match.params.id
);
return data;
}, [match.params.id]),
{ isLoading: true }
);
useEffect(() => {
fetchInventory();
}, [fetchInventory, location.pathname]);
useEffect(() => {
if (inventory) {
setBreadcrumb(inventory);
}
}, [inventory, setBreadcrumb]);
const tabsArray = [
{
name: (
<>
<CaretLeftIcon />
{t`Back to Inventories`}
</>
),
link: `/inventories`,
id: 99,
},
{ name: t`Details`, link: `${match.url}/details`, id: 0 },
{ name: t`Access`, link: `${match.url}/access`, id: 1 },
{ name: t`Hosts`, link: `${match.url}/hosts`, id: 2 },
{ name: t`Groups`, link: `${match.url}/groups`, id: 3 },
{
name: t`Jobs`,
link: `${match.url}/jobs`,
id: 4,
},
{ name: t`Job Templates`, link: `${match.url}/job_templates`, id: 5 },
];
if (hasContentLoading) {
return (
<PageSection>
<Card>
<ContentLoading />
</Card>
</PageSection>
);
}
if (contentError) {
return (
<PageSection>
<Card>
<ContentError error={contentError}>
{contentError?.response?.status === 404 && (
<span>
{t`Constructed Inventory not found.`}{' '}
<Link to="/inventories">{t`View all Inventories.`}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
if (inventory && inventory?.kind !== 'constructed') {
return <Redirect to={`${getInventoryPath(inventory)}/details`} />;
}
let showCardHeader = true;
if (
['edit', 'add', 'groups/', 'hosts/'].some((name) =>
location.pathname.includes(name)
)
) {
showCardHeader = false;
}
return (
<PageSection>
<Card>
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
<Switch>
<Redirect
from="/inventories/constructed_inventory/:id"
to="/inventories/constructed_inventory/:id/details"
exact
/>
{inventory && [
<Route
path="/inventories/constructed_inventory/:id/details"
key="details"
>
<ConstructedInventoryDetail
inventory={inventory}
hasInventoryLoading={hasContentLoading}
/>
</Route>,
<Route
key="edit"
path="/inventories/constructed_inventory/:id/edit"
>
<ConstructedInventoryEdit />
</Route>,
<Route
path="/inventories/constructed_inventory/:id/access"
key="access"
>
<ResourceAccessList
resource={inventory}
apiModel={InventoriesAPI}
/>
</Route>,
<Route
path="/inventories/constructed_inventory/:id/hosts"
key="hosts"
>
<AdvancedInventoryHosts
inventory={inventory}
setBreadcrumb={setBreadcrumb}
/>
</Route>,
<Route
path="/inventories/constructed_inventory/:id/groups"
key="constructed_inventory_groups"
>
<InventoryGroups
inventory={inventory}
setBreadcrumb={setBreadcrumb}
/>
</Route>,
<Route
key="jobs"
path="/inventories/constructed_inventory/:id/jobs"
>
<JobList
defaultParams={{
or__job__inventory: inventory.id,
or__adhoccommand__inventory: inventory.id,
or__inventoryupdate__inventory_source__inventory:
inventory.id,
or__workflowjob__inventory: inventory.id,
}}
/>
</Route>,
<Route
key="job_templates"
path="/inventories/constructed_inventory/:id/job_templates"
>
<RelatedTemplateList
searchParams={{ inventory__id: inventory.id }}
/>
</Route>,
]}
<Route path="*" key="not-found">
<ContentError isNotFound>
{match.params.id && (
<Link
to={`/inventories/constructed_inventory/${match.params.id}/details`}
>
{t`View Constructed Inventory Details`}
</Link>
)}
</ContentError>
</Route>
</Switch>
</Card>
</PageSection>
);
}
export { ConstructedInventory as _ConstructedInventory };
export default ConstructedInventory;

View File

@@ -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('<ConstructedInventory />', () => {
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(
<ConstructedInventory setBreadcrumb={() => {}} />
);
});
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(
<ConstructedInventory setBreadcrumb={() => {}} />,
{
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);
});
});

View File

@@ -0,0 +1,18 @@
/* eslint i18next/no-literal-string: "off" */
import React from 'react';
import { Card, PageSection } from '@patternfly/react-core';
import { CardBody } from 'components/Card';
function ConstructedInventoryAdd() {
return (
<PageSection>
<Card>
<CardBody>
<div>Coming Soon!</div>
</CardBody>
</Card>
</PageSection>
);
}
export default ConstructedInventoryAdd;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ConstructedInventoryAdd from './ConstructedInventoryAdd';
describe('<ConstructedInventoryAdd />', () => {
test('initially renders successfully', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<ConstructedInventoryAdd />);
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('ConstructedInventoryAdd').length).toBe(1);
});
});

View File

@@ -0,0 +1 @@
export { default } from './ConstructedInventoryAdd';

View File

@@ -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 (
<Tooltip
position="top"
content={
<>
<div>{t`MOST RECENT SYNC`}</div>
<div>
{t`JOB ID:`} {job.id}
</div>
<div>
{t`STATUS:`} {job.status.toUpperCase()}
</div>
{job.finished && (
<div>
{t`FINISHED:`} {formatDateString(job.finished)}
</div>
)}
</>
}
key={job.id}
>
<Link to={`/jobs/inventory/${job.id}`}>
<StatusLabel status={job.status} />
</Link>
</Tooltip>
);
}
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 <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
return (
<CardBody>
<DetailList>
<Detail
label={t`Name`}
value={inventory.name}
dataCy="constructed-inventory-name"
/>
<Detail
label={t`Last Job Status`}
value={
inventorySourceSyncJob && (
<JobStatusLabel job={inventorySourceSyncJob} />
)
}
/>
<Detail
label={t`Description`}
value={inventory.description}
dataCy="constructed-inventory-description"
/>
<Detail
label={t`Type`}
value={t`Constructed Inventory`}
dataCy="constructed-inventory-type"
/>
<Detail
label={actions.limit.label}
value={inventory.limit}
helpText={actions.limit.help_text}
dataCy="constructed-inventory-limit"
/>
<Detail
label={t`Organization`}
dataCy="constructed-inventory-organization"
value={
<Link
to={`/organizations/${inventory.summary_fields?.organization.id}/details`}
>
{inventory.summary_fields?.organization.name}
</Link>
}
/>
<Detail
label={actions.total_groups.label}
value={wsInventory.total_groups}
helpText={actions.total_groups.help_text}
dataCy="constructed-inventory-total-groups"
/>
<Detail
label={actions.total_hosts.label}
value={wsInventory.total_hosts}
helpText={actions.total_hosts.help_text}
dataCy="constructed-inventory-total-hosts"
/>
<Detail
label={actions.total_inventory_sources.label}
value={wsInventory.total_inventory_sources}
helpText={actions.total_inventory_sources.help_text}
dataCy="constructed-inventory-sources"
/>
<Detail
label={actions.update_cache_timeout.label}
value={inventory.update_cache_timeout}
helpText={actions.update_cache_timeout.help_text}
dataCy="constructed-inventory-cache-timeout"
/>
<Detail
label={actions.inventory_sources_with_failures.label}
value={wsInventory.inventory_sources_with_failures}
helpText={actions.inventory_sources_with_failures.help_text}
dataCy="constructed-inventory-sources-with-failures"
/>
<Detail
label={actions.verbosity.label}
value={inventory.verbosity}
helpText={actions.verbosity.help_text}
dataCy="constructed-inventory-verbosity"
/>
{instanceGroups && (
<Detail
fullWidth
label={t`Instance Groups`}
value={<InstanceGroupLabels labels={instanceGroups} isLinkable />}
isEmpty={instanceGroups.length === 0}
dataCy="constructed-inventory-instance-groups"
/>
)}
{inventory.prevent_instance_group_fallback && (
<Detail
fullWidth
label={t`Enabled Options`}
dataCy="constructed-inventory-instance-group-fallback"
value={
<TextList component={TextListVariants.ul}>
{inventory.prevent_instance_group_fallback && (
<TextListItem component={TextListItemVariants.li}>
{t`Prevent Instance Group Fallback`}
<Popover
header={t`Prevent Instance Group Fallback`}
content={helpText.preventInstanceGroupFallback}
/>
</TextListItem>
)}
</TextList>
}
/>
)}
<Detail
fullWidth
helpText={helpText.labels}
dataCy="constructed-inventory-labels"
label={t`Labels`}
value={
<ChipGroup
numChips={5}
totalChips={inventory.summary_fields.labels?.results?.length}
>
{inventory.summary_fields.labels?.results?.map((l) => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={inventory.summary_fields.labels?.results?.length === 0}
/>
<Detail
fullWidth
label={t`Input Inventories`}
value={
<LabelGroup numLabels={5}>
{inputInventories?.map((inputInventory) => (
<Label
color="blue"
key={inputInventory.id}
render={({ className, content, componentRef }) => (
<Link
className={className}
innerRef={componentRef}
to={`/inventories/inventory/${inputInventory.id}/details`}
>
{content}
</Link>
)}
>
{inputInventory.name}
</Label>
))}
</LabelGroup>
}
isEmpty={inputInventories?.length === 0}
/>
<VariablesDetail
label={actions.source_vars.label}
helpText={helpText.variables()}
value={inventory.source_vars}
rows={4}
name="variables"
dataCy="inventory-detail-variables"
/>
<UserDateDetail
label={actions.created.label}
date={inventory.created}
user={inventory.summary_fields.created_by}
/>
<UserDateDetail
label={actions.modified.label}
date={inventory.modified}
user={inventory.summary_fields.modified_by}
/>
</DetailList>
<CardActionsRow>
{inventory?.summary_fields?.user_capabilities?.edit && (
<Button
ouiaId="inventory-detail-edit-button"
component={Link}
to={`/inventories/constructed_inventory/${inventory.id}/edit`}
>
{t`Edit`}
</Button>
)}
{inventorySource?.summary_fields?.user_capabilities?.start &&
(['new', 'running', 'pending', 'waiting'].includes(
inventorySourceSyncJob?.status
) ? (
<JobCancelButton
job={{ id: inventorySourceSyncJob.id, type: 'inventory_update' }}
errorTitle={t`Constructed Inventory Source Sync Error`}
title={t`Cancel Constructed Inventory Source Sync`}
errorMessage={t`Failed to cancel Constructed Inventory Source Sync`}
buttonText={t`Cancel Sync`}
/>
) : (
<ConstructedInventorySyncButton inventoryId={inventory.id} />
))}
{inventory?.summary_fields?.user_capabilities?.delete && (
<DeleteButton
name={inventory.name}
modalTitle={t`Delete Inventory`}
onConfirm={deleteInventory}
deleteDetailsRequests={deleteDetailsRequests}
deleteMessage={t`This inventory is currently being used by other resources. Are you sure you want to delete it?`}
>
{t`Delete`}
</DeleteButton>
)}
</CardActionsRow>
{error && (
<AlertModal
isOpen={error}
variant="error"
title={t`Error!`}
onClose={dismissError}
>
{t`Failed to delete inventory.`}
<ErrorDetail error={error} />
</AlertModal>
)}
</CardBody>
);
}
ConstructedInventoryDetail.propTypes = {
inventory: Inventory.isRequired,
};
export default ConstructedInventoryDetail;

View File

@@ -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('<ConstructedInventoryDetail />', () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/constructed_inventory/1/details'],
});
const Component = (props) => (
<I18nProvider i18n={i18n}>
<Router history={history}>
<ConstructedInventoryDetail inventory={mockInventory} {...props} />
</Router>
</I18nProvider>
);
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(<Component />);
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(<Component />);
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(<Component />);
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(<Component />);
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
expect(
screen.getByText(
'There was an error loading this content. Please reload the page.'
)
).toBeInTheDocument();
});
});

View File

@@ -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 (
<>
<Tooltip content={t`Start sync process`} position="top">
<Button
ouiaId={testId}
isDisabled={startSyncLoading}
aria-label={t`Start inventory source sync`}
variant="secondary"
onClick={startSyncProcess}
>
{t`Sync`}
</Button>
</Tooltip>
{startError && (
<AlertModal
isOpen={startError}
variant="error"
title={t`Error!`}
onClose={dismissStartError}
>
{t`Failed to sync constructed inventory source`}
<ErrorDetail error={startError} />
</AlertModal>
)}
</>
);
}
ConstructedInventorySyncButton.propTypes = {
inventoryId: PropTypes.number.isRequired,
};
export default ConstructedInventorySyncButton;

View File

@@ -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('<ConstructedInventorySyncButton />', () => {
const Component = () => (
<ConstructedInventorySyncButton inventoryId={inventory.id} />
);
test('should render start sync button', () => {
render(<Component />);
expect(
screen.getByRole('button', { name: 'Start inventory source sync' })
).toBeInTheDocument();
});
test('should make expected api request on sync', async () => {
render(<Component />);
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(<Component />);
await waitFor(() => {
const syncButton = screen.queryByText('Sync');
fireEvent.click(syncButton);
});
expect(screen.getByRole('dialog', { name: 'Alert modal Error!' }));
});
});

View File

@@ -0,0 +1 @@
export { default } from './ConstructedInventoryDetail';

View File

@@ -0,0 +1,13 @@
/* eslint i18next/no-literal-string: "off" */
import React from 'react';
import { CardBody } from 'components/Card';
function ConstructedInventoryEdit() {
return (
<CardBody>
<div>Coming Soon!</div>
</CardBody>
);
}
export default ConstructedInventoryEdit;

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import ConstructedInventoryEdit from './ConstructedInventoryEdit';
describe('<ConstructedInventoryEdit />', () => {
test('initially renders successfully', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<ConstructedInventoryEdit />);
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('ConstructedInventoryEdit').length).toBe(1);
});
});

View File

@@ -0,0 +1 @@
export { default } from './ConstructedInventoryEdit';

View File

@@ -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() {
<Route path="/inventories/smart_inventory/add">
<SmartInventoryAdd />
</Route>
<Route path="/inventories/constructed_inventory/add">
<ConstructedInventoryAdd />
</Route>
<Route path="/inventories/inventory/:id">
<Config>
{({ me }) => (
@@ -119,6 +123,9 @@ function Inventories() {
<Route path="/inventories/smart_inventory/:id">
<SmartInventory setBreadcrumb={setBreadcrumbConfig} />
</Route>
<Route path="/inventories/constructed_inventory/:id">
<ConstructedInventory setBreadcrumb={setBreadcrumbConfig} />
</Route>
<Route path="/inventories">
<PersistentFilters pageKey="inventories">
<InventoryList />

View File

@@ -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 (
<Redirect to={`/inventories/smart_inventory/${inventory.id}/details`} />
);
if (inventory && inventory?.kind !== '') {
return <Redirect to={`${getInventoryPath(inventory)}/details`} />;
}
return (

View File

@@ -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 && <RoutedTabs tabsArray={tabsArray} />}
<Switch>
<Redirect
from="/inventories/inventory/:id/groups/:groupId"
to="/inventories/inventory/:id/groups/:groupId/details"
from="/inventories/:inventoryType/:id/groups/:groupId"
to="/inventories/:inventoryType/:id/groups/:groupId/details"
exact
/>
{inventoryGroup && [
<Route
key="edit"
path="/inventories/inventory/:id/groups/:groupId/edit"
path="/inventories/:inventoryType/:id/groups/:groupId/edit"
>
<InventoryGroupEdit inventoryGroup={inventoryGroup} />
</Route>,
<Route
key="details"
path="/inventories/inventory/:id/groups/:groupId/details"
path="/inventories/:inventoryType/:id/groups/:groupId/details"
>
<InventoryGroupDetail inventoryGroup={inventoryGroup} />
</Route>,
<Route
key="hosts"
path="/inventories/inventory/:id/groups/:groupId/nested_hosts"
path="/inventories/:inventoryType/:id/groups/:groupId/nested_hosts"
>
<InventoryGroupHosts inventoryGroup={inventoryGroup} />
</Route>,
<Route
key="relatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups"
path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups"
>
<InventoryRelatedGroups />
</Route>,
@@ -138,7 +138,7 @@ function InventoryGroup({ setBreadcrumb, inventory }) {
<Route key="not-found" path="*">
<ContentError>
{inventory && (
<Link to={`/inventories/inventory/${inventory.id}/details`}>
<Link to={`/inventories/:inventoryType/${inventory.id}/details`}>
{t`View Inventory Details`}
</Link>
)}

View File

@@ -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('<InventoryGroup />', () => {
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('<InventoryGroup />', () => {
},
});
history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/1/details'],
initialEntries: [`/inventories/inventory/1/groups/1/details`],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/inventories/inventory/:id/groups">
<Route path="/inventories/:inventoryType/:id/groups">
<InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
</Route>,
{ context: { router: { history } } }
@@ -63,7 +64,7 @@ describe('<InventoryGroup />', () => {
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('<InventoryGroup />', () => {
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('<InventoryGroup />', () => {
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(
<Route path="/inventories/:inventoryType/:id/groups">
<InventoryGroup setBreadcrumb={() => {}} inventory={inventory} />
</Route>,
{ 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');
});
});

View File

@@ -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 (
<CardBody>
@@ -47,31 +46,33 @@ function InventoryGroupDetail({ inventoryGroup }) {
user={modified_by}
/>
</DetailList>
<CardActionsRow>
{user_capabilities?.edit && (
<Button
ouiaId="inventory-group-detail-edit-button"
variant="primary"
aria-label={t`Edit`}
onClick={() =>
history.push(
`/inventories/inventory/${params.id}/groups/${params.groupId}/edit`
)
}
>
{t`Edit`}
</Button>
)}
{user_capabilities?.delete && (
<InventoryGroupsDeleteModal
groups={[inventoryGroup]}
isDisabled={false}
onAfterDelete={() =>
history.push(`/inventories/inventory/${params.id}/groups`)
}
/>
)}
</CardActionsRow>
{inventoryType !== 'constructed_inventory' && (
<CardActionsRow>
{user_capabilities?.edit && (
<Button
ouiaId="inventory-group-detail-edit-button"
variant="primary"
aria-label={t`Edit`}
onClick={() =>
history.push(
`/inventories/inventory/${id}/groups/${groupId}/edit`
)
}
>
{t`Edit`}
</Button>
)}
{user_capabilities?.delete && (
<InventoryGroupsDeleteModal
groups={[inventoryGroup]}
isDisabled={false}
onAfterDelete={() =>
history.push(`/inventories/inventory/${id}/groups`)
}
/>
)}
</CardActionsRow>
)}
{error && (
<AlertModal
variant="error"

View File

@@ -39,6 +39,14 @@ describe('<InventoryGroupDetail />', () => {
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('<InventoryGroupDetail />', () => {
});
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('<InventoryGroupDetail />', () => {
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(
<Route path="/inventories/inventory/:id/groups/:groupId">
<InventoryGroupDetail inventoryGroup={inventoryGroup} />
</Route>,
{
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);
});
});
});

View File

@@ -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() {
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={t`Disassociate host from group?`}
modalNote={t`
...(isNotConstructedInventory
? [
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
itemsToDisassociate={selected}
modalTitle={t`Disassociate host from group?`}
modalNote={t`
Note that only hosts directly in this group can
be disassociated. Hosts in sub-groups must be disassociated
directly from the sub-group level that they belong.
`}
/>,
/>,
]
: []),
]}
/>
)}
@@ -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)}
/>

View File

@@ -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('<InventoryGroupHostList />', () => {
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
groupId: 2,
}),
}));
let wrapper;
beforeEach(async () => {
@@ -303,3 +304,64 @@ describe('<InventoryGroupHostList />', () => {
expect(wrapper.find('AdHocCommands')).toHaveLength(0);
});
});
describe('<InventoryGroupHostList> 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(
<Route path="/inventories/:inventoryType/:id/groups/:groupId/hosts">
<InventoryGroupHostList />
</Route>,
{ 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);
});
});

View File

@@ -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({
>
<HostToggle host={host} />
</ActionItem>
<ActionItem
tooltip={t`Edit Host`}
visible={host.summary_fields.user_capabilities?.edit}
>
<Tooltip content={t`Edit Host`} position="top">
<Button
ouiaId={`${host.id}-edit-button`}
aria-label={t`Edit Host`}
variant="plain"
component={Link}
to={`${editUrl}`}
>
<PencilAltIcon />
</Button>
</Tooltip>
</ActionItem>
{inventoryType !== 'constructed_inventory' && (
<ActionItem
tooltip={t`Edit Host`}
visible={host.summary_fields.user_capabilities?.edit}
>
<Tooltip content={t`Edit Host`} position="top">
<Button
ouiaId={`${host.id}-edit-button`}
aria-label={t`Edit Host`}
variant="plain"
component={Link}
to={`${editUrl}`}
>
<PencilAltIcon />
</Button>
</Tooltip>
</ActionItem>
)}
</ActionsTd>
</Tr>
);

View File

@@ -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('<InventoryGroupHostListItem />', () => {
let wrapper;
const mockHost = mockHosts.results[0];
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/groups/2/hosts'],
});
beforeEach(() => {
wrapper = mountWithContexts(
<table>
<tbody>
<InventoryGroupHostListItem
detailUrl="/host/1"
editUrl="/host/1"
host={mockHost}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
<Route path="/inventories/:inventoryType/:id/groups/:groupId/hosts">
<table>
<tbody>
<InventoryGroupHostListItem
detailUrl="/host/1"
editUrl="/host/1"
host={mockHost}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
</Route>,
{ context: { router: { history } } }
);
});
@@ -52,19 +59,60 @@ describe('<InventoryGroupHostListItem />', () => {
const copyMockHost = { ...mockHost };
copyMockHost.summary_fields.user_capabilities.edit = false;
wrapper = mountWithContexts(
<table>
<tbody>
<InventoryGroupHostListItem
detailUrl="/host/1"
editUrl="/host/1"
host={mockHost}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
<Route path="/inventories/:inventoryType/:id/groups/:groupId/hosts">
<table>
<tbody>
<InventoryGroupHostListItem
detailUrl="/host/1"
editUrl="/host/1"
host={mockHost}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
</Route>,
{ context: { router: { history } } }
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});
describe('<InventoryGroupHostListItem> 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(
<Route path="/inventories/:inventoryType/:id/groups/:groupId/hosts">
<table>
<tbody>
<InventoryGroupHostListItem
detailUrl="/host/1"
editUrl="/host/1"
host={mockHost}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
</Route>,
{ context: { router: { history } } }
);
});
test('Edit button hidden for constructed inventory', () => {
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});

View File

@@ -9,7 +9,7 @@ function InventoryGroupHosts({ inventoryGroup }) {
<Route path="/inventories/inventory/:id/groups/:groupId/nested_hosts/add">
<InventoryGroupHostAdd inventoryGroup={inventoryGroup} />
</Route>
<Route path="/inventories/inventory/:id/groups/:groupId/nested_hosts">
<Route path="/inventories/:inventoryType/:id/groups/:groupId/nested_hosts">
<InventoryGroupHostList />
</Route>
</Switch>

View File

@@ -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 (
<Tr id={`group-row-${group.id}`} ouiaId={`group-row-${group.id}`}>
@@ -36,29 +31,30 @@ function InventoryGroupItem({
<b>{group.name}</b>
</Link>
</Td>
<ActionsTd dataLabel={t`Actions`} gridColumns="auto 40px">
<ActionItem
visible={group.summary_fields.user_capabilities.edit}
tooltip={t`Edit group`}
>
<Button
ouiaId={`${group.id}-edit-button`}
aria-label={t`Edit Group`}
variant="plain"
component={Link}
to={editUrl}
{inventoryType !== 'constructed_inventory' && (
<ActionsTd dataLabel={t`Actions`} gridColumns="auto 40px">
<ActionItem
visible={group.summary_fields.user_capabilities.edit}
tooltip={t`Edit group`}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
<Button
ouiaId={`${group.id}-edit-button`}
aria-label={t`Edit Group`}
variant="plain"
component={Link}
to={editUrl}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
)}
</Tr>
);
}
InventoryGroupItem.propTypes = {
group: Group.isRequired,
inventoryId: oneOfType([number, string]).isRequired,
isSelected: bool.isRequired,
onSelect: func.isRequired,
};

View File

@@ -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('<InventoryGroupItem />', () => {
);
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(
<Route path="/inventories/:inventoryType/:id/groups">
<table>
<tbody>
<InventoryGroupItem
group={mockGroup}
inventoryId={1}
isSelected={false}
onSelect={() => {}}
/>
</tbody>
</table>
</Route>
);
});
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});

View File

@@ -16,11 +16,14 @@ function InventoryGroups({ setBreadcrumb, inventory }) {
inventory={inventory}
/>
</Route>
<Route key="details" path="/inventories/inventory/:id/groups/:groupId/">
<Route
key="details"
path="/inventories/:inventoryType/:id/groups/:groupId/"
>
<InventoryGroup inventory={inventory} setBreadcrumb={setBreadcrumb} />
</Route>
<Route key="list" path="/inventories/inventory/:id/groups">
<InventoryGroupsList />
<Route key="list" path="/inventories/:inventoryType/:id/groups">
<InventoryGroupsList inventory={inventory} />
</Route>
</Switch>
);

View File

@@ -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 (
<PaginatedTable
@@ -139,14 +141,13 @@ function InventoryGroupsList() {
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell>
{isNotConstructedInventory && <HeaderCell>{t`Actions`}</HeaderCell>}
</HeaderRow>
}
renderRow={(item, index) => (
<InventoryGroupItem
key={item.id}
group={item}
inventoryId={inventoryId}
isSelected={selected.some((row) => row.id === item.id)}
onSelect={() => handleSelect(item)}
rowIndex={index}
@@ -177,20 +178,28 @@ function InventoryGroupsList() {
/>,
]
: []),
<Tooltip content={renderTooltip()} position="top" key="delete">
<div>
<InventoryGroupsDeleteModal
groups={selected}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
onAfterDelete={() => {
fetchData();
clearSelected();
}}
/>
</div>
</Tooltip>,
...(isNotConstructedInventory
? [
<Tooltip
content={renderTooltip()}
position="top"
key="delete"
>
<div>
<InventoryGroupsDeleteModal
groups={selected}
isDisabled={
selected.length === 0 || selected.some(cannotDelete)
}
onAfterDelete={() => {
fetchData();
clearSelected();
}}
/>
</div>
</Tooltip>,
]
: []),
]}
/>
)}

View File

@@ -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('<InventoryGroupsList />', () => {
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('<InventoryGroupsList />', () => {
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/inventories/inventory/:id/groups">
<Route path="/inventories/:inventoryType/:id/groups">
<InventoryGroupsList />
</Route>,
{
@@ -316,3 +317,77 @@ describe('<InventoryGroupsList/> 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(
<Route path="/inventories/:inventoryType/:id/groups">
<InventoryGroupsList />
</Route>,
{
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);
});
});

View File

@@ -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 = (
<AddDropDownButton
ouiaId="add-inventory-button"
@@ -158,6 +159,15 @@ function InventoryList() {
>
{addSmartInventory}
</DropdownItem>,
<DropdownItem
ouiaId="add-constructed-inventory-item"
to={`${match.url}/constructed_inventory/add/`}
component={Link}
key={addConstructedInventory}
aria-label={addConstructedInventory}
>
{addConstructedInventory}
</DropdownItem>,
]}
/>
);
@@ -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);

View File

@@ -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 ? (
<b>{inventory.name}</b>
) : (
<Link to={`${detailUrl}`}>
<Link to={`${getInventoryPath(inventory)}/details`}>
<b>{inventory.name}</b>
</Link>
)}
</TdBreakWord>
<Td dataLabel={t`Status`}>
{inventory.kind !== 'smart' &&
{inventory.kind === '' &&
(inventory.has_inventory_sources ? (
<Link
to={`/inventories/inventory/${inventory.id}/jobs?job.or__inventoryupdate__inventory_source__inventory__id=${inventory.id}`}
to={`${getInventoryPath(
inventory
)}/jobs?job.or__inventoryupdate__inventory_source__inventory__id=${
inventory.id
}`}
>
<StatusLabel
status={syncStatus}
@@ -113,9 +122,7 @@ function InventoryListItem({
<StatusLabel status={syncStatus} tooltipContent={tooltipContent} />
))}
</Td>
<Td dataLabel={t`Type`}>
{inventory.kind === 'smart' ? t`Smart Inventory` : t`Inventory`}
</Td>
<Td dataLabel={t`Type`}>{typeLabel[inventory.kind]}</Td>
<TdBreakWord key="organization" dataLabel={t`Organization`}>
<Link
to={`/organizations/${inventory?.summary_fields?.organization?.id}/details`}
@@ -139,9 +146,7 @@ function InventoryListItem({
aria-label={t`Edit Inventory`}
variant="plain"
component={Link}
to={`/inventories/${
inventory.kind === 'smart' ? 'smart_inventory' : 'inventory'
}/${inventory.id}/edit`}
to={`${getInventoryPath(inventory)}edit`}
>
<PencilAltIcon />
</Button>

View File

@@ -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 (
<>
<PaginatedTable
@@ -218,19 +219,23 @@ function InventoryRelatedGroupList() {
/>,
]
: []),
<DisassociateButton
key="disassociate"
onDisassociate={disassociateGroups}
itemsToDisassociate={selected}
modalTitle={t`Disassociate related group(s)?`}
/>,
...(isNotConstructedInventory
? [
<DisassociateButton
key="disassociate"
onDisassociate={disassociateGroups}
itemsToDisassociate={selected}
modalTitle={t`Disassociate related group(s)?`}
/>,
]
: []),
]}
/>
)}
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell>
{isNotConstructedInventory && <HeaderCell>{t`Actions`}</HeaderCell>}
</HeaderRow>
}
renderRow={(group, index) => (

View File

@@ -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('<InventoryRelatedGroupList />', () => {
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('<InventoryRelatedGroupList />', () => {
GroupsAPI.readPotentialGroups.mockResolvedValue({
data: { count: mockGroups.length, results: mockGroups },
});
await act(async () => {
wrapper = mountWithContexts(<InventoryRelatedGroupList />);
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/2/groups/2/nested_groups'],
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
await act(async () => {
wrapper = mountWithContexts(
<Route path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups">
<InventoryRelatedGroupList />
</Route>,
{ 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('<InventoryRelatedGroupList />', () => {
.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('<InventoryRelatedGroupList />', () => {
expect(wrapper.find('AdHocCommands')).toHaveLength(0);
});
});
describe('<InventoryRelatedGroupList> for constructed inventories', () => {
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
id: 1,
groupId: 2,
inventoryType: 'constructed_inventory',
}),
}));
let wrapper;
beforeEach(async () => {
GroupsAPI.readChildren.mockResolvedValue({
data: { ...mockRelatedGroups },
});
InventoriesAPI.readGroupsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [
'parents__search',
'inventory__search',
'inventory_sources__search',
'created_by__search',
'children__search',
'modified_by__search',
'hosts__search',
],
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['shell', 'shell'],
],
},
},
POST: {},
},
},
});
const history = createMemoryHistory({
initialEntries: [
'/inventories/constructed_inventory/1/groups/2/nested_groupss',
],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups">
<InventoryRelatedGroupList />
</Route>,
{ 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);
});
});

View File

@@ -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 (
<Tr
id={group.id}
@@ -41,22 +41,24 @@ function InventoryRelatedGroupListItem({
<b>{group.name}</b>
</Link>
</Td>
<ActionsTd dataLabel={t`Actions`}>
<ActionItem
tooltip={t`Edit Group`}
visible={group.summary_fields.user_capabilities?.edit}
>
<Button
ouiaId={`${group.id}-edit-button`}
aria-label={t`Edit Group`}
variant="plain"
component={Link}
to={`${editUrl}`}
{inventoryType !== 'constructed_inventory' && (
<ActionsTd dataLabel={t`Actions`}>
<ActionItem
tooltip={t`Edit Group`}
visible={group.summary_fields.user_capabilities?.edit}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
<Button
ouiaId={`${group.id}-edit-button`}
aria-label={t`Edit Group`}
variant="plain"
component={Link}
to={`${editUrl}`}
>
<PencilAltIcon />
</Button>
</ActionItem>
</ActionsTd>
)}
</Tr>
);
}

View File

@@ -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('<InventoryRelatedGroupListItem />', () => {
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(
<table>
<tbody>
<InventoryRelatedGroupListItem
detailUrl="/group/1"
editUrl="/group/1"
group={mockGroup}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
<Route path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups">
<table>
<tbody>
<InventoryRelatedGroupListItem
detailUrl="/group/1"
editUrl="/group/1"
group={mockGroup}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
</Route>,
{ context: { router: { history } } }
);
});
@@ -36,18 +51,60 @@ describe('<InventoryRelatedGroupListItem />', () => {
test('edit button hidden from users without edit capabilities', () => {
wrapper = mountWithContexts(
<table>
<tbody>
<InventoryRelatedGroupListItem
detailUrl="/group/1"
editUrl="/group/1"
group={mockRelatedGroups.results[2]}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
<Route path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups">
<table>
<tbody>
<InventoryRelatedGroupListItem
detailUrl="/group/1"
editUrl="/group/1"
group={mockRelatedGroups.results[2]}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
</Route>,
{ context: { router: { history } } }
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});
});
describe('<InventoryRelatedGroupList> 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(
<Route path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups">
<table>
<tbody>
<InventoryRelatedGroupListItem
detailUrl="/group/1"
editUrl="/group/1"
group={mockGroup}
isSelected={false}
onSelect={() => {}}
rowIndex={0}
/>
</tbody>
</table>
</Route>,
{ context: { router: { history } } }
);
expect(wrapper.find('PencilAltIcon').exists()).toBeFalsy();
});

View File

@@ -8,13 +8,13 @@ function InventoryRelatedGroups() {
<Switch>
<Route
key="addRelatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups/add"
path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups/add"
>
<InventoryRelatedGroupAdd />
</Route>
<Route
key="relatedGroups"
path="/inventories/inventory/:id/groups/:groupId/nested_groups"
path="/inventories/:inventoryType/:id/groups/:groupId/nested_groups"
>
<InventoryRelatedGroupList />
</Route>

View File

@@ -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 }) {

View File

@@ -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;
}

View File

@@ -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 <Redirect to={`/inventories/inventory/${inventory.id}/details`} />;
if (inventory && inventory?.kind !== 'smart') {
return <Redirect to={`${getInventoryPath(inventory)}/details`} />;
}
let showCardHeader = true;
@@ -141,7 +142,7 @@ function SmartInventory({ setBreadcrumb }) {
/>
</Route>,
<Route key="hosts" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHosts
<AdvancedInventoryHosts
inventory={inventory}
setBreadcrumb={setBreadcrumb}
/>

View File

@@ -1 +0,0 @@
export { default } from './SmartInventoryHost';

View File

@@ -1 +0,0 @@
export { default } from './SmartInventoryHostDetail';

View File

@@ -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 (
<Switch>
<Route key="host" path="/inventories/smart_inventory/:id/hosts/:hostId">
<SmartInventoryHost
setBreadcrumb={setBreadcrumb}
inventory={inventory}
/>
</Route>
<Route key="host-list" path="/inventories/smart_inventory/:id/hosts">
<SmartInventoryHostList inventory={inventory} />
</Route>
</Switch>
);
}
SmartInventoryHosts.propTypes = {
inventory: Inventory.isRequired,
};
export default SmartInventoryHosts;

View File

@@ -1 +0,0 @@
export { default } from './SmartInventoryHosts';

View File

@@ -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,
},
},
};
}

View File

@@ -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 <div />;
}
@@ -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();
});
});

View File

@@ -8,3 +8,12 @@ const parseHostFilter = (value) => {
return value;
};
export default parseHostFilter;
export function getInventoryPath(inventory) {
const url = {
'': `/inventories/inventory/${inventory.id}`,
smart: `/inventories/smart_inventory/${inventory.id}`,
constructed: `/inventories/constructed_inventory/${inventory.id}`,
};
return url[inventory.kind];
}

View File

@@ -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'
);
});
});

View File

@@ -54,7 +54,7 @@ options:
kind:
description:
- The kind field. Cannot be modified after created.
choices: ["", "smart"]
choices: ["", "smart", "constructed"]
type: str
host_filter:
description:
@@ -65,6 +65,11 @@ options:
- list of Instance Groups for this Organization to run on.
type: list
elements: str
input_inventories:
description:
- List of Inventories to use as input for Constructed Inventory.
type: list
elements: str
prevent_instance_group_fallback:
description:
- Prevent falling back to instance groups set on the organization
@@ -111,11 +116,12 @@ def main():
description=dict(),
organization=dict(required=True),
variables=dict(type='dict'),
kind=dict(choices=['', 'smart']),
kind=dict(choices=['', 'smart', 'constructed']),
host_filter=dict(),
instance_groups=dict(type="list", elements='str'),
prevent_instance_group_fallback=dict(type='bool'),
state=dict(choices=['present', 'absent'], default='present'),
input_inventories=dict(type='list', elements='str'),
)
# Create a module for ourselves
@@ -181,6 +187,13 @@ def main():
if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart':
module.fail_json(msg='You cannot turn a regular inventory into a "smart" inventory.')
if kind == 'constructed':
input_inventory_names = module.params.get('input_inventories')
if input_inventory_names is not None:
association_fields['input_inventories'] = []
for item in input_inventory_names:
association_fields['input_inventories'].append(module.resolve_name_to_id('inventories', item))
# If the state was present and we can let the module build or update the existing inventory, this will return on its own
module.create_or_update_if_needed(
inventory,

View File

@@ -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.
@@ -167,6 +171,7 @@ def main():
enabled_var=dict(),
enabled_value=dict(),
host_filter=dict(),
limit=dict(),
credential=dict(),
execution_environment=dict(),
custom_virtualenv=dict(),
@@ -272,6 +277,7 @@ def main():
'enabled_var',
'enabled_value',
'host_filter',
'limit',
)
# Layer in all remaining optional information

View File

@@ -20,7 +20,9 @@ read_only_endpoints_with_modules = ['settings', 'role', 'project_update']
# 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_inventories', # 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 = [

View File

@@ -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):

View File

@@ -57,7 +57,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/'

View File

@@ -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.