diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 32a13b35d9..3f3e9b5556 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1204,6 +1204,7 @@ class HostSerializer(BaseSerializerWithVariables): job_host_summaries = self.reverse('api:host_job_host_summaries_list', kwargs={'pk': obj.pk}), activity_stream = self.reverse('api:host_activity_stream_list', kwargs={'pk': obj.pk}), inventory_sources = self.reverse('api:host_inventory_sources_list', kwargs={'pk': obj.pk}), + smart_inventories = self.reverse('api:host_smart_inventories_list', kwargs={'pk': obj.pk}), ad_hoc_commands = self.reverse('api:host_ad_hoc_commands_list', kwargs={'pk': obj.pk}), ad_hoc_command_events = self.reverse('api:host_ad_hoc_command_events_list', kwargs={'pk': obj.pk}), fact_versions = self.reverse('api:host_fact_versions_list', kwargs={'pk': obj.pk}), diff --git a/awx/api/urls.py b/awx/api/urls.py index 80f5eb7248..6e1cd0ba4a 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -115,6 +115,7 @@ host_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/job_host_summaries/$', 'host_job_host_summaries_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'host_activity_stream_list'), url(r'^(?P[0-9]+)/inventory_sources/$', 'host_inventory_sources_list'), + url(r'^(?P[0-9]+)/smart_inventories/$', 'host_smart_inventories_list'), url(r'^(?P[0-9]+)/ad_hoc_commands/$', 'host_ad_hoc_commands_list'), url(r'^(?P[0-9]+)/ad_hoc_command_events/$', 'host_ad_hoc_command_events_list'), #url(r'^(?P[0-9]+)/single_fact/$', 'host_single_fact_view'), diff --git a/awx/api/views.py b/awx/api/views.py index 8116f35bbb..d2a0d95920 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -58,7 +58,7 @@ import ansiconv from social.backends.utils import load_backends # AWX -from awx.main.tasks import send_notifications +from awx.main.tasks import send_notifications, update_host_smart_inventory_memberships from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TaskAuthentication, TokenGetAuthentication @@ -2006,6 +2006,22 @@ class HostInventorySourcesList(SubListAPIView): new_in_148 = True +class HostSmartInventoriesList(SubListAPIView): + model = Inventory + serializer_class = InventorySerializer + parent_model = Host + relationship = 'smart_inventories' + new_in_320 = True + + def list(self, *args, **kwargs): + try: + if settings.AWX_REBUILD_SMART_MEMBERSHIP: + update_host_smart_inventory_memberships.delay() + return super(HostSmartInventoriesList, self).list(*args, **kwargs) + except Exception as e: + return Response(dict(error=_(unicode(e))), status=status.HTTP_400_BAD_REQUEST) + + class HostActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView): model = ActivityStream diff --git a/awx/main/migrations/0038_v320_release.py b/awx/main/migrations/0038_v320_release.py index afc1e7a1ad..08f91edfe5 100644 --- a/awx/main/migrations/0038_v320_release.py +++ b/awx/main/migrations/0038_v320_release.py @@ -36,6 +36,8 @@ class Migration(migrations.Migration): name='inventory', field=models.ForeignKey(related_name='inventory_sources', default=None, to='main.Inventory', null=True), ), + + # Smart Inventory migrations.AddField( model_name='inventory', name='host_filter', @@ -44,7 +46,28 @@ class Migration(migrations.Migration): migrations.AddField( model_name='inventory', name='kind', - field=models.CharField(default=b'', help_text='Kind of inventory being represented.', max_length=32, choices=[(b'', 'Hosts have a direct link to this inventory.'), (b'smart', 'Hosts for inventory generated using the host_filter property.')]), + field=models.CharField(default=b'', help_text='Kind of inventory being represented.', max_length=32, blank=True, choices=[(b'', 'Hosts have a direct link to this inventory.'), (b'smart', 'Hosts for inventory generated using the host_filter property.')]), + ), + migrations.CreateModel( + name='SmartInventoryMembership', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('host', models.ForeignKey(related_name='+', to='main.Host')), + ], + ), + migrations.AddField( + model_name='smartinventorymembership', + name='inventory', + field=models.ForeignKey(related_name='+', to='main.Inventory'), + ), + migrations.AddField( + model_name='host', + name='smart_inventories', + field=models.ManyToManyField(related_name='_host_smart_inventories_+', through='main.SmartInventoryMembership', to='main.Inventory'), + ), + migrations.AlterUniqueTogether( + name='smartinventorymembership', + unique_together=set([('host', 'inventory')]), ), # Facts diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 4b4f867a44..8f34d01e6b 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -36,7 +36,8 @@ from awx.main.models.notifications import ( ) from awx.main.utils import _inventory_updates -__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'CustomInventoryScript'] +__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', + 'CustomInventoryScript', 'SmartInventoryMembership'] logger = logging.getLogger('awx.main.models.inventory') @@ -346,6 +347,19 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): return self.groups.exclude(parents__pk__in=group_pks).distinct() +class SmartInventoryMembership(BaseModel): + ''' + A lookup table for Host membership in Smart Inventory + ''' + + class Meta: + app_label = 'main' + unique_together = (('host', 'inventory'),) + + inventory = models.ForeignKey('Inventory', related_name='+', on_delete=models.CASCADE) + host = models.ForeignKey('Host', related_name='+', on_delete=models.CASCADE) + + class Host(CommonModelNameNotUnique): ''' A managed node @@ -361,6 +375,11 @@ class Host(CommonModelNameNotUnique): related_name='hosts', on_delete=models.CASCADE, ) + smart_inventories = models.ManyToManyField( + 'Inventory', + related_name='+', + through='SmartInventoryMembership', + ) enabled = models.BooleanField( default=True, help_text=_('Is this host online and available for running jobs?'), diff --git a/awx/main/tasks.py b/awx/main/tasks.py index c0eeec4764..0cb7c5a9c6 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -38,7 +38,7 @@ from celery.signals import celeryd_init, worker_process_init # Django from django.conf import settings -from django.db import transaction, DatabaseError +from django.db import transaction, DatabaseError, IntegrityError from django.utils.timezone import now from django.utils.encoding import smart_str from django.core.mail import send_mail @@ -62,8 +62,8 @@ from awx.main.utils.handlers import configure_external_logger from awx.main.consumers import emit_channel_notification __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', - 'RunAdHocCommand', 'handle_work_error', - 'handle_work_success', 'update_inventory_computed_fields', + 'RunAdHocCommand', 'handle_work_error', 'handle_work_success', + 'update_inventory_computed_fields', 'update_host_smart_inventory_memberships', 'send_notifications', 'run_administrative_checks', 'purge_old_stdout_files'] HIDDEN_PASSWORD = '**********' @@ -321,6 +321,22 @@ def update_inventory_computed_fields(inventory_id, should_update_hosts=True): i.update_computed_fields(update_hosts=should_update_hosts) +@task(queue='tower') +def update_host_smart_inventory_memberships(): + try: + with transaction.atomic(): + smart_inventories = Inventory.objects.filter(kind='smart', host_filter__isnull=False) + SmartInventoryMembership.objects.all().delete() + memberships = [] + for smart_inventory in smart_inventories: + memberships.extend([SmartInventoryMembership(inventory_id=smart_inventory.id, host_id=host_id[0]) + for host_id in smart_inventory.hosts.values_list('id')]) + SmartInventoryMembership.objects.bulk_create(memberships) + except IntegrityError as e: + logger.error("Update Host Smart Inventory Memberships failed due to an exception: " + str(e)) + return + + class BaseTask(Task): name = None model = None diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 3c6709c92c..cf69a88537 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -568,6 +568,9 @@ AWX_TASK_ENV = {} # Flag to enable/disable updating hosts M2M when saving job events. CAPTURE_JOB_EVENT_HOSTS = False +# Rebuild Host Smart Inventory memberships. +AWX_REBUILD_SMART_MEMBERSHIP = False + # Enable bubblewrap support for running jobs (playbook runs only). # Note: This setting may be overridden by database settings. AWX_PROOT_ENABLED = True diff --git a/docs/inventory_refresh.md b/docs/inventory_refresh.md index 8cbbd06c00..8cd6a8e6b9 100644 --- a/docs/inventory_refresh.md +++ b/docs/inventory_refresh.md @@ -23,10 +23,16 @@ in our _Smart Search_. * The `Inventory` model has a new field called `kind`. The default of this field will be blank for normal inventories and set to `smart` for smart inventories. -* `Inventory` model as a new field called `host_filter`. The default of this field will be blank +* `Inventory` model has a new field called `host_filter`. The default of this field will be blank for normal inventories. When `host_filter` is set AND the inventory `kind` is set to `smart` is the combination that makes a _Smart Inventory_. +* `Host` model has a new field called `smart_inventories`. This field uses the `SmartInventoryMemberships` +lookup table to provide a set of all of the _Smart Inventory_ a host is a part of. The memberships +or generated by the `update_host_smart_inventory_memberships` task. This task is called when the view for +`/api/v2/hosts/:id/smart_inventories` is materialized. NOTE: This task is only run if the +`AWX_REBUILD_SMART_MEMBERSHIP` is set to True. It defaults to False. + ### Smart Filter (host_filter) The `SmartFilter` class handles our translation of the smart search string. We store the filter value in the `host_filter` field for an inventory. This value should be expressed