# Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. import logging import uuid from django.db import models from django.conf import settings from django.db.models import OuterRef, Subquery from django.db.models.functions import Lower from ansible_base.lib.utils.db import advisory_lock from awx.main.utils.filters import SmartFilter from awx.main.constants import RECEPTOR_PENDING ___all__ = ['HostManager', 'InstanceManager', 'DeferJobCreatedManager', 'UUID_DEFAULT'] logger = logging.getLogger('awx.main.managers') UUID_DEFAULT = '00000000-0000-0000-0000-000000000000' class DeferJobCreatedManager(models.Manager): def get_queryset(self): return super(DeferJobCreatedManager, self).get_queryset().defer('job_created') class HostLatestSummaryQuerySet(models.QuerySet): """Queryset that annotates and bulk-attaches the latest JobHostSummary at queryset evaluation time, similar to prefetch_related(). Why not use Django's Prefetch? Django's Prefetch with [:1] slicing fetches 1 record globally, not per-host (Django ticket #26780). Window-function workarounds require Django 4.2+ and are more complex. Prefetching all summaries then filtering in Python wastes memory for hosts with many job runs. The approach here — annotate the latest ID via Subquery, then in_bulk() only those IDs — is the same 2-query pattern prefetch_related uses internally, customized for "latest per group." Not streaming-safe: relies on _result_cache existing after _fetch_all(). """ _awx_latest_summary_attached = False def _clone(self): clone = super()._clone() clone._awx_latest_summary_attached = self._awx_latest_summary_attached return clone def with_latest_summary_id(self): from awx.main.models.jobs import JobHostSummary latest_summary = JobHostSummary.objects.filter(host_id=OuterRef('pk')).order_by('-id') return self.annotate( _latest_summary_id=Subquery(latest_summary.values('id')[:1]), ) def _fetch_all(self): super()._fetch_all() if self._awx_latest_summary_attached or not self._result_cache: return # Only bulk-attach if the queryset was annotated via with_latest_summary_id(). # Without this guard, we'd set _latest_summary_cache=None on every host, # masking the per-object fallback query in Host.latest_summary. if not hasattr(self._result_cache[0], '_latest_summary_id'): return from awx.main.models.jobs import JobHostSummary latest_summary_ids = [host._latest_summary_id for host in self._result_cache if host._latest_summary_id is not None] if latest_summary_ids: summaries_by_id = JobHostSummary.objects.select_related('job', 'job__job_template').in_bulk(latest_summary_ids) else: summaries_by_id = {} for host in self._result_cache: latest_summary_id = getattr(host, '_latest_summary_id', None) host._latest_summary_cache = summaries_by_id.get(latest_summary_id) self._awx_latest_summary_attached = True class HostManager(models.Manager.from_queryset(HostLatestSummaryQuerySet)): """Custom manager class for Hosts model.""" def active_count(self): """Return count of active, unique hosts for licensing. Construction of query involves: - remove any ordering specified in model's Meta - Exclude hosts sourced from another Tower - Exclude hosts in constructed inventories (these are shadow rows of source-inventory hosts) - Restrict the query to only return the name column - Only consider results that are unique - Return the count of this query """ return ( self.order_by() .exclude(inventory_sources__source='controller') .exclude(inventory__kind='constructed') .values(name_lower=Lower('name')) .distinct() .count() ) def org_active_count(self, org_id): """Return count of active, unique hosts used by an organization. Construction of query involves: - remove any ordering specified in model's Meta - Exclude hosts sourced from another Tower - Exclude hosts in constructed inventories (these are shadow rows of source-inventory hosts) - Consider only hosts where the canonical inventory is owned by the organization - Restrict the query to only return the name column - Only consider results that are unique - Return the count of this query """ return ( self.order_by() .exclude(inventory_sources__source='controller') .exclude(inventory__kind='constructed') .filter(inventory__organization=org_id) .values('name') .distinct() .count() ) def get_queryset(self): """When the parent instance of the host query set has a `kind=smart` and a `host_filter` set. Use the `host_filter` to generate the queryset for the hosts. """ qs = super().get_queryset().defer('ansible_facts') if hasattr(self, 'instance') and hasattr(self.instance, 'host_filter') and hasattr(self.instance, 'kind'): if self.instance.kind == 'smart' and self.instance.host_filter is not None: q = SmartFilter.query_from_string(self.instance.host_filter) if self.instance.organization_id: q = q.filter(inventory__organization=self.instance.organization_id) # If we are using host_filters, disable the core_filters, this allows # us to access all of the available Host entries, not just the ones associated # with a specific FK/relation. # # If we don't disable this, a filter of {'inventory': self.instance} gets automatically # injected by the related object mapper. self.core_filters = {} qs = qs & q return qs.order_by('name', 'pk').distinct('name') return qs class HostMetricActiveManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(deleted=False) def get_ig_ig_mapping(ig_instance_mapping, instance_ig_mapping): # Create IG mapping by union of all groups their instances are members of ig_ig_mapping = {} for group_name in ig_instance_mapping.keys(): ig_ig_set = set() for instance_hostname in ig_instance_mapping[group_name]: ig_ig_set |= instance_ig_mapping[instance_hostname] else: ig_ig_set.add(group_name) # Group contains no instances, return self ig_ig_mapping[group_name] = ig_ig_set return ig_ig_mapping class InstanceManager(models.Manager): """A custom manager class for the Instance model. Provides "table-level" methods including getting the currently active instance or role. """ def my_hostname(self): return settings.CLUSTER_HOST_ID def me(self): """Return the currently active instance.""" node = self.filter(hostname=self.my_hostname()) if node.exists(): return node[0] raise RuntimeError("No instance found with the current cluster host id") def register( self, node_uuid=None, hostname=None, ip_address="", node_type='hybrid', defaults=None, ): if not hostname: hostname = settings.CLUSTER_HOST_ID if not ip_address: ip_address = "" with advisory_lock('instance_registration_%s' % hostname): if settings.AWX_AUTO_DEPROVISION_INSTANCES: # detect any instances with the same IP address. # if one exists, set it to "" if ip_address: inst_conflicting_ip = self.filter(ip_address=ip_address).exclude(hostname=hostname) if inst_conflicting_ip.exists(): for other_inst in inst_conflicting_ip: other_hostname = other_inst.hostname other_inst.ip_address = "" other_inst.save(update_fields=['ip_address']) logger.warning("IP address {0} conflict detected, ip address unset for host {1}.".format(ip_address, other_hostname)) # Return existing instance that matches hostname or UUID (default to UUID) if node_uuid is not None and node_uuid != UUID_DEFAULT and self.filter(uuid=node_uuid).exists(): instance = self.filter(uuid=node_uuid) else: # if instance was not retrieved by uuid and hostname was, use the hostname instance = self.filter(hostname=hostname) from awx.main.models import Instance # Return existing instance if instance.exists(): instance = instance.first() # in the unusual occasion that there is more than one, only get one instance.node_state = Instance.States.INSTALLED # Wait for it to show up on the mesh update_fields = ['node_state'] # if instance was retrieved by uuid and hostname has changed, update hostname if instance.hostname != hostname: logger.warning("passed in hostname {0} is different from the original hostname {1}, updating to {0}".format(hostname, instance.hostname)) instance.hostname = hostname update_fields.append('hostname') # if any other fields are to be updated if instance.ip_address != ip_address: instance.ip_address = ip_address update_fields.append('ip_address') if instance.node_type != node_type: instance.node_type = node_type update_fields.append('node_type') if update_fields: instance.save(update_fields=update_fields) return (True, instance) else: return (False, instance) # Create new instance, and fill in default values create_defaults = { 'node_state': Instance.States.INSTALLED, 'capacity': 0, 'managed': True, } if defaults is not None: create_defaults.update(defaults) uuid_option = {'uuid': node_uuid if node_uuid is not None else uuid.uuid4()} if node_type == 'execution' and 'version' not in create_defaults: create_defaults['version'] = RECEPTOR_PENDING instance = self.create(hostname=hostname, ip_address=ip_address, node_type=node_type, **create_defaults, **uuid_option) return (True, instance)