querysets.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. import heapq
  2. import netaddr
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.db.models import Count, F, OuterRef, Q, Subquery, Value
  5. from django.db.models.expressions import RawSQL
  6. from django.db.models.functions import Cast, NullIf, Round
  7. from utilities.query import count_related
  8. from utilities.querysets import RestrictedQuerySet
  9. from .fields import IPAddressField
  10. from .lookups import Host
  11. __all__ = (
  12. 'ASNRangeQuerySet',
  13. 'IPAddressQuerySet',
  14. 'IPRangeQuerySet',
  15. 'PrefixQuerySet',
  16. 'VLANGroupQuerySet',
  17. 'VLANQuerySet',
  18. )
  19. # The host portion of an IP address (mask ignored), in the same form as the
  20. # ipam_ipaddress_host expression index.
  21. HOST_ADDRESS = Cast(Host('address'), output_field=IPAddressField())
  22. def _merge_intervals(intervals):
  23. """
  24. Return the union of (start, end) netaddr.IPAddress intervals, merged and sorted.
  25. """
  26. if not intervals:
  27. return []
  28. intervals = sorted(intervals)
  29. merged = [intervals[0]]
  30. for start, end in intervals[1:]:
  31. current_start, current_end = merged[-1]
  32. # Adjacency math in int space; netaddr raises at the address-space maximum.
  33. if start.version == current_end.version and int(start) <= int(current_end) + 1:
  34. merged[-1] = (current_start, max(current_end, end))
  35. else:
  36. merged.append((start, end))
  37. return merged
  38. class ASNRangeQuerySet(RestrictedQuerySet):
  39. def annotate_asn_counts(self):
  40. """
  41. Annotate the number of ASNs which appear within each range.
  42. """
  43. from .models import ASN
  44. # Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
  45. # that we can use to count ASNs and return a single value per ASNRange.
  46. asns = ASN.objects.filter(
  47. asn__gte=OuterRef('start'),
  48. asn__lte=OuterRef('end')
  49. ).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
  50. return self.annotate(asn_count=Subquery(asns))
  51. class IPAddressQuerySet(RestrictedQuerySet):
  52. def count_distinct_hosts(self, exclude_intervals=()):
  53. """
  54. Count distinct host addresses, optionally excluding (start, end) netaddr.IPAddress intervals.
  55. """
  56. queryset = self
  57. for start, end in exclude_intervals:
  58. queryset = queryset.exclude(address__host_between=(start, end))
  59. return queryset.aggregate(count=Count(HOST_ADDRESS, distinct=True))['count']
  60. def count_distinct_hosts_pair(self, bounds, bounded_exclude=(), total_exclude=()):
  61. """
  62. Return two distinct host counts computed in a single scan, as a dict:
  63. 'bounded' counts hosts within the (first_ip, last_ip) bounds excluding the
  64. bounded_exclude intervals; 'total' counts all hosts excluding the
  65. total_exclude intervals. Interval arguments match the output of
  66. IPRangeQuerySet.get_intervals(). Avoids a second scan of the host expression
  67. index when both counts are needed. Use only when both counts are needed (e.g.
  68. Prefix.get_ip_usage_summary()); single-purpose callers should prefer
  69. count_distinct_hosts().
  70. """
  71. # The deduplicated column is already a bare host; plain comparisons beat
  72. # the host_between lookup here, which would re-wrap it in HOST()::inet.
  73. bounded_q = Q(host_address__range=(str(bounds[0]), str(bounds[1])))
  74. for start, end in bounded_exclude:
  75. bounded_q &= ~Q(host_address__range=(str(start), str(end)))
  76. total_q = Q()
  77. for start, end in total_exclude:
  78. total_q &= ~Q(host_address__range=(str(start), str(end)))
  79. hosts = self.order_by().annotate(host_address=HOST_ADDRESS).values('host_address').distinct()
  80. return hosts.aggregate(
  81. bounded=Count('host_address', filter=bounded_q),
  82. # An empty Q is falsy; fall back to a plain count of all hosts.
  83. total=Count('host_address', filter=total_q or None),
  84. )
  85. def _iter_distinct_hosts(self, first_ip, last_ip, batch_size):
  86. """
  87. Yield the distinct occupied hosts in [first_ip, last_ip] in ascending order,
  88. fetched in LIMIT batches that resume just past the last seen host. (A
  89. server-side cursor is unsuitable here: on autocommit connections Django
  90. declares it WITH HOLD, which materializes the full result at DECLARE.)
  91. """
  92. resume = first_ip
  93. while True:
  94. # order_by() first clears the default ordering, which would otherwise
  95. # leak into SELECT and break distinct().
  96. hosts = list(
  97. self.filter(address__host_between=(resume, last_ip))
  98. .order_by()
  99. .annotate(host_address=HOST_ADDRESS)
  100. .values_list('host_address', flat=True)
  101. .distinct()
  102. .order_by('host_address')[:batch_size]
  103. )
  104. for host in hosts:
  105. yield host.ip
  106. if len(hosts) < batch_size:
  107. return
  108. last_host = hosts[-1].ip
  109. if int(last_host) >= int(last_ip):
  110. return
  111. resume = netaddr.IPAddress(int(last_host) + 1, version=last_host.version)
  112. def available_intervals(self, first_ip, last_ip, exclude_intervals=(), batch_size=5000):
  113. """
  114. Yield the unoccupied (start, end) netaddr.IPAddress intervals (inclusive)
  115. within [first_ip, last_ip], in ascending order. exclude_intervals are
  116. (start, end) netaddr.IPAddress pairs; they are merged and sorted internally,
  117. intervals of a foreign address family are ignored, and addresses they cover
  118. count as occupied. Consumption is lazy: a caller that stops early stops
  119. fetching host batches.
  120. """
  121. if batch_size < 1:
  122. raise ValueError('batch_size must be greater than zero')
  123. first_int, last_int = int(first_ip), int(last_ip)
  124. version = first_ip.version
  125. if first_int > last_int:
  126. return
  127. # Normalize: the sweep below requires sorted, non-overlapping, same-family intervals.
  128. exclude_intervals = _merge_intervals([
  129. (start, end)
  130. for start, end in exclude_intervals
  131. if start.version == end.version == version
  132. ])
  133. intervals = [(int(start), int(end)) for start, end in exclude_intervals]
  134. # Fast path: one merged excluded interval covers the entire span.
  135. if intervals and intervals[0][0] <= first_int and intervals[0][1] >= last_int:
  136. return
  137. hosts = (
  138. (int(host), int(host))
  139. for host in self._iter_distinct_hosts(first_ip, last_ip, batch_size)
  140. )
  141. candidate = first_int
  142. # Ties on `start` are harmless; the sweep handles overlapping intervals.
  143. for start, end in heapq.merge(intervals, hosts):
  144. if end < candidate:
  145. continue
  146. if start > candidate:
  147. yield (
  148. netaddr.IPAddress(candidate, version=version),
  149. netaddr.IPAddress(min(start - 1, last_int), version=version),
  150. )
  151. candidate = max(candidate, end + 1)
  152. if candidate > last_int:
  153. return
  154. if candidate <= last_int:
  155. yield (
  156. netaddr.IPAddress(candidate, version=version),
  157. netaddr.IPAddress(last_int, version=version),
  158. )
  159. def first_available_host(self, first_ip, last_ip, exclude_intervals=()):
  160. """
  161. Return the first host in [first_ip, last_ip] neither present nor in an excluded interval (or None).
  162. """
  163. interval = next(self.available_intervals(first_ip, last_ip, exclude_intervals), None)
  164. return interval[0] if interval else None
  165. class IPRangeQuerySet(RestrictedQuerySet):
  166. def get_intervals(self, first_ip=None, last_ip=None):
  167. """
  168. Return ranges as merged (start, end) netaddr.IPAddress intervals, optionally clipped to the bounds.
  169. """
  170. intervals = []
  171. # order_by() clears the default ordering; _merge_intervals() sorts anyway.
  172. for start_address, end_address in self.order_by().values_list('start_address', 'end_address'):
  173. start, end = start_address.ip, end_address.ip
  174. if first_ip is not None:
  175. if end < first_ip:
  176. continue
  177. start = max(start, first_ip)
  178. if last_ip is not None:
  179. if start > last_ip:
  180. continue
  181. end = min(end, last_ip)
  182. intervals.append((start, end))
  183. return _merge_intervals(intervals)
  184. class PrefixQuerySet(RestrictedQuerySet):
  185. def annotate_hierarchy(self):
  186. """
  187. Annotate the depth and number of child prefixes for each Prefix. Cast null VRF values to zero for
  188. comparison. (NULL != NULL).
  189. """
  190. return self.annotate(
  191. hierarchy_depth=RawSQL(
  192. 'SELECT COUNT(DISTINCT U0."prefix") AS "c" '
  193. 'FROM "ipam_prefix" U0 '
  194. 'WHERE (U0."prefix" >> "ipam_prefix"."prefix" '
  195. 'AND COALESCE(U0."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))',
  196. ()
  197. ),
  198. hierarchy_children=RawSQL(
  199. 'SELECT COUNT(U1."prefix") AS "c" '
  200. 'FROM "ipam_prefix" U1 '
  201. 'WHERE (U1."prefix" << "ipam_prefix"."prefix" '
  202. 'AND COALESCE(U1."vrf_id", 0) = COALESCE("ipam_prefix"."vrf_id", 0))',
  203. ()
  204. )
  205. )
  206. class VLANGroupQuerySet(RestrictedQuerySet):
  207. def annotate_utilization(self):
  208. from .models import VLAN
  209. # NullIf guards against legacy rows where total_vlan_ids was miscounted to
  210. # 0 by the pre-fix VLANGroup.save(); without it, the annotation 500s.
  211. return self.annotate(
  212. vlan_count=count_related(VLAN, 'group'),
  213. utilization=Round(F('vlan_count') * 100.0 / NullIf(F('total_vlan_ids'), Value(0)), 2),
  214. )
  215. class VLANQuerySet(RestrictedQuerySet):
  216. def get_for_site(self, site):
  217. """
  218. Return all VLANs in the specified site
  219. """
  220. from .models import VLANGroup
  221. q = Q()
  222. q |= Q(
  223. scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
  224. scope_id=site.pk
  225. )
  226. if site.region:
  227. q |= Q(
  228. scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
  229. scope_id__in=site.region.get_ancestors(include_self=True)
  230. )
  231. if site.group:
  232. q |= Q(
  233. scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
  234. scope_id__in=site.group.get_ancestors(include_self=True)
  235. )
  236. return self.filter(
  237. Q(group__in=VLANGroup.objects.filter(q)) |
  238. Q(site=site) |
  239. Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
  240. Q(group__isnull=True, site__isnull=True) # Global VLANs
  241. )
  242. def get_for_site_group(self, site_group):
  243. """
  244. Return all VLANs available to the specified site group.
  245. """
  246. if site_group is None:
  247. return self.none()
  248. from .models import VLANGroup
  249. q = Q(
  250. scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
  251. scope_id__in=site_group.get_ancestors(include_self=True)
  252. )
  253. return self.filter(
  254. Q(group__in=VLANGroup.objects.filter(q)) |
  255. Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
  256. Q(group__isnull=True, site__isnull=True) # Global VLANs
  257. )
  258. def get_for_device(self, device):
  259. """
  260. Return all VLANs available to the specified Device.
  261. """
  262. from .models import VLANGroup
  263. # Find all relevant VLANGroups
  264. q = Q()
  265. if device.cluster_id:
  266. # The Device's physical scope is evaluated below. For valid assignments,
  267. # the Cluster's physical scope is already represented by that hierarchy.
  268. q |= Q(
  269. scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
  270. scope_id=device.cluster_id
  271. )
  272. if device.cluster.group_id:
  273. q |= Q(
  274. scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
  275. scope_id=device.cluster.group_id
  276. )
  277. if device.site.region:
  278. q |= Q(
  279. scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
  280. scope_id__in=device.site.region.get_ancestors(include_self=True)
  281. )
  282. if device.site.group:
  283. q |= Q(
  284. scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
  285. scope_id__in=device.site.group.get_ancestors(include_self=True)
  286. )
  287. q |= Q(
  288. scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
  289. scope_id=device.site_id
  290. )
  291. if device.location:
  292. q |= Q(
  293. scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'),
  294. scope_id__in=device.location.get_ancestors(include_self=True)
  295. )
  296. if device.rack:
  297. q |= Q(
  298. scope_type=ContentType.objects.get_by_natural_key('dcim', 'rack'),
  299. scope_id=device.rack_id
  300. )
  301. # Return all applicable VLANs
  302. return self.filter(
  303. Q(group__in=VLANGroup.objects.filter(q)) |
  304. Q(site=device.site) |
  305. Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
  306. Q(group__isnull=True, site__isnull=True) # Global VLANs
  307. )
  308. def get_for_virtualmachine(self, vm):
  309. """
  310. Return all VLANs available to the specified VirtualMachine.
  311. """
  312. from .models import VLANGroup
  313. # Find all relevant VLANGroups
  314. q = Q()
  315. site = vm.site
  316. if vm.cluster:
  317. # Add VLANGroups scoped to the assigned cluster (or its group)
  318. q |= Q(
  319. scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
  320. scope_id=vm.cluster_id
  321. )
  322. if vm.cluster.group:
  323. q |= Q(
  324. scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
  325. scope_id=vm.cluster.group_id
  326. )
  327. # Looking all possible cluster scopes
  328. if vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'location'):
  329. site = site or vm.cluster.scope.site
  330. q |= Q(
  331. scope_type=ContentType.objects.get_by_natural_key('dcim', 'location'),
  332. scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
  333. )
  334. elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'site'):
  335. site = site or vm.cluster.scope
  336. q |= Q(
  337. scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
  338. scope_id=vm.cluster.scope.pk
  339. )
  340. elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'sitegroup'):
  341. q |= Q(
  342. scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
  343. scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
  344. )
  345. elif vm.cluster.scope_type == ContentType.objects.get_by_natural_key('dcim', 'region'):
  346. q |= Q(
  347. scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
  348. scope_id__in=vm.cluster.scope.get_ancestors(include_self=True)
  349. )
  350. # VM can be assigned to a site without a cluster so checking assigned site independently
  351. if site:
  352. # Add VLANGroups scoped to the assigned site (or its group or region)
  353. q |= Q(
  354. scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
  355. scope_id=site.pk
  356. )
  357. if site.region:
  358. q |= Q(
  359. scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
  360. scope_id__in=site.region.get_ancestors(include_self=True)
  361. )
  362. if site.group:
  363. q |= Q(
  364. scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
  365. scope_id__in=site.group.get_ancestors(include_self=True)
  366. )
  367. vlan_groups = VLANGroup.objects.filter(q)
  368. # Return all applicable VLANs
  369. q = (
  370. Q(group__in=vlan_groups) |
  371. Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
  372. Q(group__isnull=True, site__isnull=True) # Global VLANs
  373. )
  374. if site:
  375. q |= Q(site=site)
  376. return self.filter(q)