From 57e005b7753de1c5c66a1c71ce69dfaa868b78ac Mon Sep 17 00:00:00 2001 From: Alan Rominger Date: Fri, 20 Jan 2023 09:21:59 -0500 Subject: [PATCH] Start on new constructed inventory API view Make the GET function work at most basic level Basic functionality of updating working Add functional test for the GET and PATCH views Add constructed inventory list view for direct creation Add limit field to constructed inventory serializer --- awx/api/serializers.py | 76 +++++++++++++++++++ awx/api/urls/inventory.py | 10 ++- awx/api/urls/urls.py | 3 +- awx/api/views/inventory.py | 17 ++++- awx/api/views/root.py | 1 + .../migrations/0182_constructed_inventory.py | 1 - awx/main/tasks/jobs.py | 2 +- .../tests/functional/api/test_inventory.py | 42 ++++++++++ awx/settings/defaults.py | 2 + 9 files changed, 149 insertions(+), 5 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f927347f63..3406dca77a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1695,6 +1695,7 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) if obj.kind == 'constructed': res['source_inventories'] = self.reverse('api:inventory_source_inventories', kwargs={'pk': obj.pk}) + res['url'] = self.reverse('api:constructed_inventory_detail', kwargs={'pk': obj.pk}) return res def to_representation(self, obj): @@ -1736,6 +1737,81 @@ class InventorySerializer(LabelsListMixin, BaseSerializerWithVariables): return super(InventorySerializer, self).validate(attrs) +class ConstructedFieldMixin(serializers.Field): + def get_attribute(self, instance): + if not hasattr(instance, '_constructed_inv_src'): + instance._constructed_inv_src = instance.inventory_sources.first() + inv_src = instance._constructed_inv_src + return super().get_attribute(inv_src) # yoink + + +class ConstructedCharField(ConstructedFieldMixin, serializers.CharField): + pass + + +class ConstructedIntegerField(ConstructedFieldMixin, serializers.IntegerField): + pass + + +class ConstructedInventorySerializer(InventorySerializer): + source_vars = ConstructedCharField( + required=False, + default=None, + allow_blank=True, + help_text=_('The source_vars for the related auto-created inventory source, special to constructed inventory.'), + ) + update_cache_timeout = ConstructedIntegerField( + required=False, + allow_null=True, + min_value=0, + default=None, + help_text=_('The cache timeout for the related auto-created inventory source, special to constructed inventory'), + ) + limit = ConstructedCharField( + required=False, + default=None, + allow_blank=True, + help_text=_('The limit to restrict the returned hosts for the related auto-created inventory source, special to constructed inventory.'), + ) + + class Meta: + model = Inventory + fields = ('*', '-host_filter', 'source_vars', 'update_cache_timeout', 'limit') + + def pop_inv_src_data(self, data): + inv_src_data = {} + for field in ('source_vars', 'update_cache_timeout', 'limit'): + if field in data: + # values always need to be removed, as they are not valid for Inventory model + value = data.pop(field) + # null is not valid for any of those fields, taken as not-provided + if value is not None: + inv_src_data[field] = value + return inv_src_data + + def apply_inv_src_data(self, inventory, inv_src_data): + if inv_src_data: + update_fields = [] + inv_src = inventory.inventory_sources.first() + for field, value in inv_src_data.items(): + setattr(inv_src, field, value) + update_fields.append(field) + if update_fields: + inv_src.save(update_fields=update_fields) + + def create(self, validated_data): + inv_src_data = self.pop_inv_src_data(validated_data) + inventory = super().create(validated_data) + self.apply_inv_src_data(inventory, inv_src_data) + return inventory + + def update(self, obj, validated_data): + inv_src_data = self.pop_inv_src_data(validated_data) + obj = super().update(obj, validated_data) + self.apply_inv_src_data(obj, inv_src_data) + return obj + + class InventoryScriptSerializer(InventorySerializer): class Meta: fields = () diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index c7d3592c93..3be18d5249 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -6,6 +6,8 @@ from django.urls import re_path from awx.api.views.inventory import ( InventoryList, InventoryDetail, + ConstructedInventoryDetail, + ConstructedInventoryList, InventoryActivityStreamList, InventorySourceInventoriesList, InventoryJobTemplateList, @@ -50,4 +52,10 @@ urls = [ re_path(r'^(?P[0-9]+)/copy/$', InventoryCopy.as_view(), name='inventory_copy'), ] -__all__ = ['urls'] +# Constructed inventory special views +constructed_inventory_urls = [ + re_path(r'^$', ConstructedInventoryList.as_view(), name='constructed_inventory_list'), + re_path(r'^(?P[0-9]+)/$', ConstructedInventoryDetail.as_view(), name='constructed_inventory_detail'), +] + +__all__ = ['urls', 'constructed_inventory_urls'] diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 9eafb51d64..bb27710dcc 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -47,7 +47,7 @@ from .organization import urls as organization_urls from .user import urls as user_urls from .project import urls as project_urls from .project_update import urls as project_update_urls -from .inventory import urls as inventory_urls +from .inventory import urls as inventory_urls, constructed_inventory_urls from .execution_environments import urls as execution_environment_urls from .team import urls as team_urls from .host import urls as host_urls @@ -119,6 +119,7 @@ v2_urls = [ re_path(r'^project_updates/', include(project_update_urls)), re_path(r'^teams/', include(team_urls)), re_path(r'^inventories/', include(inventory_urls)), + re_path(r'^constructed_inventories/', include(constructed_inventory_urls)), re_path(r'^hosts/', include(host_urls)), re_path(r'^host_metrics/', include(host_metric_urls)), # It will be enabled in future version of the AWX diff --git a/awx/api/views/inventory.py b/awx/api/views/inventory.py index 032069ea1f..f18bc29fe2 100644 --- a/awx/api/views/inventory.py +++ b/awx/api/views/inventory.py @@ -31,6 +31,7 @@ from awx.api.views.labels import LabelSubListCreateAttachDetachView from awx.api.serializers import ( InventorySerializer, + ConstructedInventorySerializer, ActivityStreamSerializer, RoleSerializer, InstanceGroupSerializer, @@ -79,7 +80,9 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie # Do not allow changes to an Inventory kind. if kind is not None and obj.kind != kind: - return Response(dict(error=_('You cannot turn a regular inventory into a "smart" inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED) + return Response( + dict(error=_('You cannot turn a regular inventory into a "smart" or "constructed" inventory.')), status=status.HTTP_405_METHOD_NOT_ALLOWED + ) return super(InventoryDetail, self).update(request, *args, **kwargs) def destroy(self, request, *args, **kwargs): @@ -94,6 +97,18 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIVie return Response(dict(error=_("{0}".format(e))), status=status.HTTP_400_BAD_REQUEST) +class ConstructedInventoryDetail(InventoryDetail): + serializer_class = ConstructedInventorySerializer + + +class ConstructedInventoryList(InventoryList): + serializer_class = ConstructedInventorySerializer + + def get_queryset(self): + r = super().get_queryset() + return r.filter(kind='constructed') + + class InventorySourceInventoriesList(SubListAttachDetachAPIView): model = Inventory serializer_class = InventorySerializer diff --git a/awx/api/views/root.py b/awx/api/views/root.py index be4d9cc44b..7f33fac4af 100644 --- a/awx/api/views/root.py +++ b/awx/api/views/root.py @@ -98,6 +98,7 @@ class ApiVersionRootView(APIView): data['tokens'] = reverse('api:o_auth2_token_list', request=request) data['metrics'] = reverse('api:metrics_view', request=request) data['inventory'] = reverse('api:inventory_list', request=request) + data['constructed_inventory'] = reverse('api:constructed_inventory_list', request=request) data['inventory_sources'] = reverse('api:inventory_source_list', request=request) data['inventory_updates'] = reverse('api:inventory_update_list', request=request) data['groups'] = reverse('api:group_list', request=request) diff --git a/awx/main/migrations/0182_constructed_inventory.py b/awx/main/migrations/0182_constructed_inventory.py index 3c37fc66e4..ff268d376f 100644 --- a/awx/main/migrations/0182_constructed_inventory.py +++ b/awx/main/migrations/0182_constructed_inventory.py @@ -4,7 +4,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ('main', '0181_hostmetricsummarymonthly'), ] diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 8a68a000d4..5b874e14d0 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -320,7 +320,7 @@ class BaseTask(object): for hostname, hv in script_data.get('_meta', {}).get('hostvars', {}).items(): # maintain a list of host_name --> host_id # so we can associate emitted events to Host objects - self.runner_callback.host_map[hostname] = hv.pop('remote_tower_id', '') + self.runner_callback.host_map[hostname] = hv.get('remote_tower_id', '') file_content = '#! /usr/bin/env python3\n# -*- coding: utf-8 -*-\nprint(%r)\n' % json.dumps(script_data) return self.write_private_data_file(private_data_dir, file_name, file_content, sub_dir='inventory', file_permissions=0o700) diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index 80357a22f9..c8139a340a 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -594,3 +594,45 @@ class TestControlledBySCM: rando, expect=403, ) + + +@pytest.mark.django_db +class TestConstructedInventory: + @pytest.fixture + def constructed_inventory(self, organization): + return Inventory.objects.create(name='constructed-test-inventory', kind='constructed', organization=organization) + + def test_get_constructed_inventory(self, constructed_inventory, admin_user, get): + inv_src = constructed_inventory.inventory_sources.first() + inv_src.update_cache_timeout = 53 + inv_src.save(update_fields=['update_cache_timeout']) + r = get(url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), user=admin_user, expect=200) + assert r.data['update_cache_timeout'] == 53 + + def test_patch_constructed_inventory(self, constructed_inventory, admin_user, patch): + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 0 + assert inv_src.limit == '' + r = patch( + url=reverse('api:constructed_inventory_detail', kwargs={'pk': constructed_inventory.pk}), + data=dict(update_cache_timeout=54, limit='foobar'), + user=admin_user, + expect=200, + ) + assert r.data['update_cache_timeout'] == 54 + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 54 + assert inv_src.limit == 'foobar' + + def test_create_constructed_inventory(self, constructed_inventory, admin_user, post, organization): + r = post( + url=reverse('api:constructed_inventory_list'), + data=dict(name='constructed-inventory-just-created', kind='constructed', organization=organization.id, update_cache_timeout=55, limit='foobar'), + user=admin_user, + expect=201, + ) + pk = r.data['id'] + constructed_inventory = Inventory.objects.get(pk=pk) + inv_src = constructed_inventory.inventory_sources.first() + assert inv_src.update_cache_timeout == 55 + assert inv_src.limit == 'foobar' diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index e8d1963b40..e7720e150b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -763,6 +763,8 @@ SCM_EXCLUDE_EMPTY_GROUPS = False # ---------------- # -- Constructed -- # ---------------- +CONSTRUCTED_INSTANCE_ID_VAR = 'remote_tower_id' + CONSTRUCTED_EXCLUDE_EMPTY_GROUPS = False # ---------------------