| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434 |
- import heapq
- import netaddr
- from django.contrib.contenttypes.models import ContentType
- from django.db.models import Count, F, OuterRef, Q, Subquery, Value
- from django.db.models.expressions import RawSQL
- from django.db.models.functions import Cast, NullIf, Round
- from utilities.query import count_related
- from utilities.querysets import RestrictedQuerySet
- from .fields import IPAddressField
- from .lookups import Host
- __all__ = (
- 'ASNRangeQuerySet',
- 'IPAddressQuerySet',
- 'IPRangeQuerySet',
- 'PrefixQuerySet',
- 'VLANGroupQuerySet',
- 'VLANQuerySet',
- )
- # The host portion of an IP address (mask ignored), in the same form as the
- # ipam_ipaddress_host expression index.
- HOST_ADDRESS = Cast(Host('address'), output_field=IPAddressField())
- def _merge_intervals(intervals):
- """
- Return the union of (start, end) netaddr.IPAddress intervals, merged and sorted.
- """
- if not intervals:
- return []
- intervals = sorted(intervals)
- merged = [intervals[0]]
- for start, end in intervals[1:]:
- current_start, current_end = merged[-1]
- # Adjacency math in int space; netaddr raises at the address-space maximum.
- if start.version == current_end.version and int(start) <= int(current_end) + 1:
- merged[-1] = (current_start, max(current_end, end))
- else:
- merged.append((start, end))
- return merged
- class ASNRangeQuerySet(RestrictedQuerySet):
- def annotate_asn_counts(self):
- """
- Annotate the number of ASNs which appear within each range.
- """
- from .models import ASN
- # Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
- # that we can use to count ASNs and return a single value per ASNRange.
- asns = ASN.objects.filter(
- asn__gte=OuterRef('start'),
- asn__lte=OuterRef('end')
- ).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
- return self.annotate(asn_count=Subquery(asns))
- class IPAddressQuerySet(RestrictedQuerySet):
- def count_distinct_hosts(self, exclude_intervals=()):
- """
- Count distinct host addresses, optionally excluding (start, end) netaddr.IPAddress intervals.
- """
- queryset = self
- for start, end in exclude_intervals:
- queryset = queryset.exclude(address__host_between=(start, end))
- return queryset.aggregate(count=Count(HOST_ADDRESS, distinct=True))['count']
- def count_distinct_hosts_pair(self, bounds, bounded_exclude=(), total_exclude=()):
- """
- Return two distinct host counts computed in a single scan, as a dict:
- 'bounded' counts hosts within the (first_ip, last_ip) bounds excluding the
- bounded_exclude intervals; 'total' counts all hosts excluding the
- total_exclude intervals. Interval arguments match the output of
- IPRangeQuerySet.get_intervals(). Avoids a second scan of the host expression
- index when both counts are needed. Use only when both counts are needed (e.g.
- Prefix.get_ip_usage_summary()); single-purpose callers should prefer
- count_distinct_hosts().
- """
- # The deduplicated column is already a bare host; plain comparisons beat
- # the host_between lookup here, which would re-wrap it in HOST()::inet.
- bounded_q = Q(host_address__range=(str(bounds[0]), str(bounds[1])))
- for start, end in bounded_exclude:
- bounded_q &= ~Q(host_address__range=(str(start), str(end)))
- total_q = Q()
- for start, end in total_exclude:
- total_q &= ~Q(host_address__range=(str(start), str(end)))
- hosts = self.order_by().annotate(host_address=HOST_ADDRESS).values('host_address').distinct()
- return hosts.aggregate(
- bounded=Count('host_address', filter=bounded_q),
- # An empty Q is falsy; fall back to a plain count of all hosts.
- total=Count('host_address', filter=total_q or None),
- )
- def _iter_distinct_hosts(self, first_ip, last_ip, batch_size):
- """
- Yield the distinct occupied hosts in [first_ip, last_ip] in ascending order,
- fetched in LIMIT batches that resume just past the last seen host. (A
- server-side cursor is unsuitable here: on autocommit connections Django
- declares it WITH HOLD, which materializes the full result at DECLARE.)
- """
- resume = first_ip
- while True:
- # order_by() first clears the default ordering, which would otherwise
- # leak into SELECT and break distinct().
- hosts = list(
- self.filter(address__host_between=(resume, last_ip))
- .order_by()
- .annotate(host_address=HOST_ADDRESS)
- .values_list('host_address', flat=True)
- .distinct()
- .order_by('host_address')[:batch_size]
- )
- for host in hosts:
- yield host.ip
- if len(hosts) < batch_size:
- return
- last_host = hosts[-1].ip
- if int(last_host) >= int(last_ip):
- return
- resume = netaddr.IPAddress(int(last_host) + 1, version=last_host.version)
- def available_intervals(self, first_ip, last_ip, exclude_intervals=(), batch_size=5000):
- """
- Yield the unoccupied (start, end) netaddr.IPAddress intervals (inclusive)
- within [first_ip, last_ip], in ascending order. exclude_intervals are
- (start, end) netaddr.IPAddress pairs; they are merged and sorted internally,
- intervals of a foreign address family are ignored, and addresses they cover
- count as occupied. Consumption is lazy: a caller that stops early stops
- fetching host batches.
- """
- if batch_size < 1:
- raise ValueError('batch_size must be greater than zero')
- first_int, last_int = int(first_ip), int(last_ip)
- version = first_ip.version
- if first_int > last_int:
- return
- # Normalize: the sweep below requires sorted, non-overlapping, same-family intervals.
- exclude_intervals = _merge_intervals([
- (start, end)
- for start, end in exclude_intervals
- if start.version == end.version == version
- ])
- intervals = [(int(start), int(end)) for start, end in exclude_intervals]
- # Fast path: one merged excluded interval covers the entire span.
- if intervals and intervals[0][0] <= first_int and intervals[0][1] >= last_int:
- return
- hosts = (
- (int(host), int(host))
- for host in self._iter_distinct_hosts(first_ip, last_ip, batch_size)
- )
- candidate = first_int
- # Ties on `start` are harmless; the sweep handles overlapping intervals.
- for start, end in heapq.merge(intervals, hosts):
- if end < candidate:
- continue
- if start > candidate:
- yield (
- netaddr.IPAddress(candidate, version=version),
- netaddr.IPAddress(min(start - 1, last_int), version=version),
- )
- candidate = max(candidate, end + 1)
- if candidate > last_int:
- return
- if candidate <= last_int:
- yield (
- netaddr.IPAddress(candidate, version=version),
- netaddr.IPAddress(last_int, version=version),
- )
- def first_available_host(self, first_ip, last_ip, exclude_intervals=()):
- """
- Return the first host in [first_ip, last_ip] neither present nor in an excluded interval (or None).
- """
- interval = next(self.available_intervals(first_ip, last_ip, exclude_intervals), None)
- return interval[0] if interval else None
- class IPRangeQuerySet(RestrictedQuerySet):
- def get_intervals(self, first_ip=None, last_ip=None):
- """
- Return ranges as merged (start, end) netaddr.IPAddress intervals, optionally clipped to the bounds.
- """
- intervals = []
- # order_by() clears the default ordering; _merge_intervals() sorts anyway.
- for start_address, end_address in self.order_by().values_list('start_address', 'end_address'):
- start, end = start_address.ip, end_address.ip
- if first_ip is not None:
- if end < first_ip:
- continue
- start = max(start, first_ip)
- if last_ip is not None:
- if start > last_ip:
- continue
- end = min(end, last_ip)
- intervals.append((start, end))
- return _merge_intervals(intervals)
- class PrefixQuerySet(RestrictedQuerySet):
- def annotate_hierarchy(self):
- """
- Annotate the depth and number of child prefixes for each Prefix. Cast null VRF values to zero for
- comparison. (NULL != NULL).
- """
- return self.annotate(
- hierarchy_depth=RawSQL(
- 'SELECT COUNT(DISTINCT U0."prefix") AS "c" '
- 'FROM "ipam_prefix" U0 '
- 'WHERE (U0."prefix" >> "ipam_prefix"."prefix" '
- 'AND COALESCE(U0."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))',
- ()
- ),
- hierarchy_children=RawSQL(
- 'SELECT COUNT(U1."prefix") AS "c" '
- 'FROM "ipam_prefix" U1 '
- 'WHERE (U1."prefix" << "ipam_prefix"."prefix" '
- 'AND COALESCE(U1."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))',
- ()
- )
- )
- class VLANGroupQuerySet(RestrictedQuerySet):
- def annotate_utilization(self):
- from .models import VLAN
- # NullIf guards against legacy rows where total_vlan_ids was miscounted to
- # 0 by the pre-fix VLANGroup.save(); without it, the annotation 500s.
- return self.annotate(
- vlan_count=count_related(VLAN, 'group'),
- utilization=Round(F('vlan_count') * 100.0 / NullIf(F('total_vlan_ids'), Value(0)), 2),
- )
- class VLANQuerySet(RestrictedQuerySet):
- def get_for_site(self, site):
- """
- Return all VLANs in the specified site
- """
- from .models import VLANGroup
- q = Q()
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
- scope_id=site.pk
- )
- if site.region:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
- scope_id__in=site.region.get_ancestors(include_self=True)
- )
- if site.group:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
- scope_id__in=site.group.get_ancestors(include_self=True)
- )
- return self.filter(
- Q(group__in=VLANGroup.objects.filter(q)) |
- Q(site=site) |
- Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
- Q(group__isnull=True, site__isnull=True) # Global VLANs
- )
- def get_for_site_group(self, site_group):
- """
- Return all VLANs available to the specified site group.
- """
- if site_group is None:
- return self.none()
- from .models import VLANGroup
- q = Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
- scope_id__in=site_group.get_ancestors(include_self=True)
- )
- return self.filter(
- Q(group__in=VLANGroup.objects.filter(q)) |
- Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
- Q(group__isnull=True, site__isnull=True) # Global VLANs
- )
- def get_for_device(self, device):
- """
- Return all VLANs available to the specified Device.
- """
- from .models import VLANGroup
- # Find all relevant VLANGroups
- q = Q()
- if device.cluster_id:
- # The Device's physical scope is evaluated below. For valid assignments,
- # the Cluster's physical scope is already represented by that hierarchy.
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
- scope_id=device.cluster_id
- )
- if device.cluster.group_id:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
- scope_id=device.cluster.group_id
- )
- if device.site.region:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
- scope_id__in=device.site.region.get_ancestors(include_self=True)
- )
- if device.site.group:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
- scope_id__in=device.site.group.get_ancestors(include_self=True)
- )
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
- scope_id=device.site_id
- )
- if device.location:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'),
- scope_id__in=device.location.get_ancestors(include_self=True)
- )
- if device.rack:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'rack'),
- scope_id=device.rack_id
- )
- # Return all applicable VLANs
- return self.filter(
- Q(group__in=VLANGroup.objects.filter(q)) |
- Q(site=device.site) |
- Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
- Q(group__isnull=True, site__isnull=True) # Global VLANs
- )
- def get_for_virtualmachine(self, vm):
- """
- Return all VLANs available to the specified VirtualMachine.
- """
- from .models import VLANGroup
- # Find all relevant VLANGroups
- q = Q()
- site = vm.site
- if vm.cluster:
- # Add VLANGroups scoped to the assigned cluster (or its group)
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
- scope_id=vm.cluster_id
- )
- if vm.cluster.group:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
- scope_id=vm.cluster.group_id
- )
- # Looking all possible cluster scopes
- if vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'location'):
- site = site or vm.cluster.scope.site
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'),
- scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
- )
- elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'site'):
- site = site or vm.cluster.scope
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
- scope_id=vm.cluster.scope.pk
- )
- elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'sitegroup'):
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
- scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
- )
- elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'region'):
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
- scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
- )
- # VM can be assigned to a site without a cluster so checking assigned site independently
- if site:
- # Add VLANGroups scoped to the assigned site (or its group or region)
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
- scope_id=site.pk
- )
- if site.region:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
- scope_id__in=site.region.get_ancestors(include_self=True)
- )
- if site.group:
- q |= Q(
- scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
- scope_id__in=site.group.get_ancestors(include_self=True)
- )
- vlan_groups = VLANGroup.objects.filter(q)
- # Return all applicable VLANs
- q = (
- Q(group__in=vlan_groups) |
- Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
- Q(group__isnull=True, site__isnull=True) # Global VLANs
- )
- if site:
- q |= Q(site=site)
- return self.filter(q)
|