Merge lp:~mpontillo/maas/unmanaged-subnets into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Rejected
Rejected by: Mike Pontillo
Proposed branch: lp:~mpontillo/maas/unmanaged-subnets
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 634 lines (+308/-52)
7 files modified
src/maasserver/migrations/builtin/maasserver/0027_replace_static_range_with_admin_reserved_ranges.py (+1/-1)
src/maasserver/migrations/builtin/maasserver/0094_add_unmanaged_subnets.py (+19/-0)
src/maasserver/models/iprange.py (+3/-2)
src/maasserver/models/subnet.py (+70/-31)
src/maasserver/models/tests/test_subnet.py (+56/-8)
src/maasserver/testing/factory.py (+3/-2)
src/provisioningserver/utils/network.py (+156/-8)
To merge this branch: bzr merge lp:~mpontillo/maas/unmanaged-subnets
Reviewer Review Type Date Requested Status
MAAS Maintainers Pending
Review via email: mp+312076@code.launchpad.net

Commit message

WIP

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

Transitioned to Git.

lp:maas has now moved from Bzr to Git.
Please propose your branches with Launchpad using Git.

git clone https://git.launchpad.net/maas

Unmerged revisions

5573. By Mike Pontillo

Unmanaged subnets WIP.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/migrations/builtin/maasserver/0027_replace_static_range_with_admin_reserved_ranges.py'
2--- src/maasserver/migrations/builtin/maasserver/0027_replace_static_range_with_admin_reserved_ranges.py 2016-05-11 19:01:48 +0000
3+++ src/maasserver/migrations/builtin/maasserver/0027_replace_static_range_with_admin_reserved_ranges.py 2016-11-29 18:04:20 +0000
4@@ -37,7 +37,7 @@
5 IPRange, subnet, ranges, created_time, range_description):
6 unreserved_range_set = MAASIPSet(ranges)
7 unreserved_ranges = unreserved_range_set.get_unused_ranges(
8- subnet.cidr, comment="reserved")
9+ subnet.cidr, purpose="reserved")
10 for iprange in unreserved_ranges:
11 start_ip = str(IPAddress(iprange.first))
12 end_ip = str(IPAddress(iprange.last))
13
14=== added file 'src/maasserver/migrations/builtin/maasserver/0094_add_unmanaged_subnets.py'
15--- src/maasserver/migrations/builtin/maasserver/0094_add_unmanaged_subnets.py 1970-01-01 00:00:00 +0000
16+++ src/maasserver/migrations/builtin/maasserver/0094_add_unmanaged_subnets.py 2016-11-29 18:04:20 +0000
17@@ -0,0 +1,19 @@
18+# -*- coding: utf-8 -*-
19+from __future__ import unicode_literals
20+
21+from django.db import migrations, models
22+
23+
24+class Migration(migrations.Migration):
25+
26+ dependencies = [
27+ ('maasserver', '0093_add_rdns_model'),
28+ ]
29+
30+ operations = [
31+ migrations.AddField(
32+ model_name='subnet',
33+ name='managed',
34+ field=models.BooleanField(default=True),
35+ ),
36+ ]
37
38=== modified file 'src/maasserver/models/iprange.py'
39--- src/maasserver/models/iprange.py 2016-10-19 19:20:24 +0000
40+++ src/maasserver/models/iprange.py 2016-11-29 18:04:20 +0000
41@@ -171,8 +171,9 @@
42 def netaddr_iprange(self):
43 return netaddr.IPRange(self.start_ip, self.end_ip)
44
45- def get_MAASIPRange(self):
46- purpose = self.type
47+ def get_MAASIPRange(self, purpose=None):
48+ if purpose is None:
49+ purpose = self.type
50 # Using '-' instead of '_' is just for consistency.
51 # APIs in previous MAAS releases used '-' in range types.
52 purpose = purpose.replace('_', '-')
53
54=== modified file 'src/maasserver/models/subnet.py'
55--- src/maasserver/models/subnet.py 2016-10-18 16:48:13 +0000
56+++ src/maasserver/models/subnet.py 2016-11-29 18:04:20 +0000
57@@ -65,6 +65,7 @@
58 MaybeIPAddress,
59 parse_integer,
60 )
61+from provisioningserver.utils.network import IPRANGE_TYPE as MAASIPRANGE_TYPE
62
63
64 maaslog = get_maas_logger("subnet")
65@@ -386,6 +387,9 @@
66 active_discovery = BooleanField(
67 editable=True, blank=False, null=False, default=False)
68
69+ managed = BooleanField(
70+ editable=True, blank=False, null=False, default=True)
71+
72 @property
73 def label(self):
74 """Returns a human-friendly label for this subnet."""
75@@ -457,8 +461,8 @@
76
77 def _get_ranges_for_allocated_ips(
78 self, ipnetwork: IPNetwork, ignore_discovered_ips: bool) -> set:
79- """Returns a set of MAASIPRange objects created from the set of allocated
80- StaticIPAddress objects.
81+ """Returns a set of MAASIPRange objects created from the set of
82+ allocated StaticIPAddress objects.
83 """
84 # Note, the original implementation used .exclude() to filter,
85 # but we'll filter at runtime so that prefetch_related in the
86@@ -472,10 +476,49 @@
87 ranges.add(make_iprange(ip, purpose="assigned-ip"))
88 return ranges
89
90+ def _add_subnet_metadata(self, ranges, exclude_addresses,
91+ ignore_discovered_ips):
92+ """Adds subnet metadata to the specified range set.
93+
94+ For IP addresses within the subnet which are either a default gateway
95+ or are a gateway specified by a static route, adds the 'gateway-ip'
96+ purpose to the set of ranges.
97+
98+ For IP addresses on the subnet which are also designated DNS servers
99+ on the subnet, adds IP addresses with the `dns-server` purpose
100+ to the set of ranges.
101+
102+ For IP addresses specified in the `exclude_addresses` parameter,
103+ adds IP addresses with the `excluded` purpose to the set of ranges.
104+ """
105+ ipnetwork = self.get_ipnetwork()
106+ if (self.gateway_ip is not None and self.gateway_ip != '' and
107+ self.gateway_ip in ipnetwork):
108+ ranges |= {make_iprange(self.gateway_ip, purpose="gateway-ip")}
109+ if self.dns_servers is not None:
110+ ranges |= set(
111+ make_iprange(server, purpose="dns-server")
112+ for server in self.dns_servers
113+ if server in ipnetwork
114+ )
115+ for static_route in StaticRoute.objects.filter(source=self):
116+ ranges |= {
117+ make_iprange(
118+ static_route.gateway_ip, purpose="gateway-ip")}
119+ ranges |= self._get_ranges_for_allocated_ips(
120+ ipnetwork, ignore_discovered_ips)
121+ ranges |= set(
122+ make_iprange(address, purpose="excluded")
123+ for address in exclude_addresses
124+ if address in ipnetwork
125+ )
126+ return ranges
127+
128 def get_ipranges_in_use(
129 self, exclude_addresses: IPAddressExcludeList=None,
130 ranges_only: bool=False,
131- ignore_discovered_ips: bool=False) -> MAASIPSet:
132+ ignore_discovered_ips: bool=False,
133+ for_reserved_allocation: bool=False) -> MAASIPSet:
134 """Returns a `MAASIPSet` of `MAASIPRange` objects which are currently
135 in use on this `Subnet`.
136
137@@ -483,6 +526,8 @@
138 :param ignore_discovered_ips: DISCOVERED addresses are not "in use".
139 :param ranges_only: if True, filters out gateway IPs, static routes,
140 DNS servers, and `exclude_addresses`.
141+ :param for_reserved_allocation: if True, filters out reserved IP
142+ ranges, so that users can use them for allocation.
143 """
144 if exclude_addresses is None:
145 exclude_addresses = []
146@@ -499,9 +544,9 @@
147 # *outside* both ranges, so that they won't conflict with addresses
148 # reserved from this scheme in the future.
149 first = str(IPAddress(network.first))
150- first_plus_one = str(IPAddress(network.first + 1))
151- second = str(IPAddress(network.first + 0xFFFFFFFF))
152 if network.prefixlen == 64:
153+ first_plus_one = str(IPAddress(network.first + 1))
154+ second = str(IPAddress(network.first + 0xFFFFFFFF))
155 ranges |= {make_iprange(
156 first_plus_one, second, purpose="reserved")}
157 # Reserve the subnet router anycast address, except for /127 and
158@@ -509,29 +554,11 @@
159 if network.prefixlen < 127:
160 ranges |= {make_iprange(
161 first, first, purpose="rfc-4291-2.6.1")}
162- ipnetwork = self.get_ipnetwork()
163 if not ranges_only:
164- if (self.gateway_ip is not None and self.gateway_ip != '' and
165- self.gateway_ip in ipnetwork):
166- ranges |= {make_iprange(self.gateway_ip, purpose="gateway-ip")}
167- if self.dns_servers is not None:
168- ranges |= set(
169- make_iprange(server, purpose="dns-server")
170- for server in self.dns_servers
171- if server in ipnetwork
172- )
173- for static_route in StaticRoute.objects.filter(source=self):
174- ranges |= {
175- make_iprange(
176- static_route.gateway_ip, purpose="gateway-ip")}
177- ranges |= self._get_ranges_for_allocated_ips(
178- ipnetwork, ignore_discovered_ips)
179- ranges |= set(
180- make_iprange(address, purpose="excluded")
181- for address in exclude_addresses
182- if address in network
183- )
184- ranges |= self.get_reserved_maasipset()
185+ ranges |= self._add_subnet_metadata(
186+ ranges, exclude_addresses, ignore_discovered_ips)
187+ if not for_reserved_allocation:
188+ ranges |= self.get_reserved_maasipset()
189 ranges |= self.get_dynamic_maasipset()
190 return MAASIPSet(ranges)
191
192@@ -554,12 +581,24 @@
193 """
194 if exclude_addresses is None:
195 exclude_addresses = []
196+ for_reserved_allocation = self.managed is False and not ranges_only
197 ranges = self.get_ipranges_in_use(
198 exclude_addresses=exclude_addresses,
199 ranges_only=ranges_only,
200- ignore_discovered_ips=ignore_discovered_ips)
201+ ignore_discovered_ips=ignore_discovered_ips,
202+ for_reserved_allocation=for_reserved_allocation
203+ )
204+ if for_reserved_allocation:
205+ # For unmanaged networks, we must reserve everything NOT in a
206+ # reserved range with the 'unmanaged' type.
207+ reserved = self.get_reserved_maasipset()
208+ unmanaged = reserved.get_unused_ranges(
209+ self.get_ipnetwork(), purpose=MAASIPRANGE_TYPE.UNMANAGED)
210+ ranges |= unmanaged
211 if with_neighbours:
212 ranges |= self.get_maasipset_for_neighbours()
213+ from pprint import pprint
214+ pprint(ranges)
215 unused = ranges.get_unused_ranges(self.get_ipnetwork())
216 return unused
217
218@@ -574,7 +613,7 @@
219 # IP addresses should already be covered by get_ipranges_in_use().
220 neighbours = Discovery.objects.filter(subnet=self).by_unknown_ip()
221 neighbour_set = {
222- make_iprange(neighbour.ip, purpose="neighbour")
223+ make_iprange(neighbour.ip, purpose=MAASIPRANGE_TYPE.NEIGHBOUR)
224 for neighbour in neighbours
225 }
226 return MAASIPSet(neighbour_set)
227@@ -717,9 +756,9 @@
228 "%s is within the dynamic range from %s to %s" % (
229 ip, IPAddress(iprange.first), IPAddress(iprange.last)))
230
231- def get_reserved_maasipset(self):
232+ def get_reserved_maasipset(self, purpose=None):
233 reserved_ranges = MAASIPSet(
234- iprange.get_MAASIPRange()
235+ iprange.get_MAASIPRange(purpose=purpose)
236 for iprange in self.get_reserved_ranges()
237 )
238 return reserved_ranges
239
240=== modified file 'src/maasserver/models/tests/test_subnet.py'
241--- src/maasserver/models/tests/test_subnet.py 2016-11-09 08:14:00 +0000
242+++ src/maasserver/models/tests/test_subnet.py 2016-11-29 18:04:20 +0000
243@@ -2,6 +2,7 @@
244 # GNU Affero General Public License version 3 (see the file LICENSE).
245
246 """Tests for the Subnet model."""
247+from maasserver.utils.orm import reload_object
248
249 __all__ = []
250
251@@ -898,6 +899,11 @@
252
253 class TestSubnetGetNextIPForAllocation(MAASServerTestCase):
254
255+ scenarios = (
256+ ("managed", {'managed': True}),
257+ # ("unmanaged", {'managed': False}),
258+ )
259+
260 def setUp(self):
261 register_view("maasserver_discovery")
262 return super().setUp()
263@@ -906,7 +912,7 @@
264 # Note: 10.0.0.0/30 --> 10.0.0.1 and 10.0.0.0.2 are usable.
265 subnet = factory.make_Subnet(
266 cidr="10.0.0.0/30", gateway_ip="10.0.0.1",
267- dns_servers=["10.0.0.2"])
268+ dns_servers=["10.0.0.2"], managed=self.managed)
269 with ExpectedException(
270 StaticIPAddressExhaustion,
271 "No more IPs available in subnet: 10.0.0.0/30."):
272@@ -915,35 +921,65 @@
273 def test__allocates_next_free_address(self):
274 # Note: 10.0.0.0/30 --> 10.0.0.1 and 10.0.0.0.2 are usable.
275 subnet = factory.make_Subnet(
276- cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None)
277+ cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None,
278+ managed=self.managed)
279+ if not self.managed:
280+ factory.make_IPRange(
281+ subnet, start_ip='10.0.0.1', end_ip='10.0.0.2',
282+ type=IPRANGE_TYPE.RESERVED)
283+ subnet = reload_object(subnet)
284 ip = subnet.get_next_ip_for_allocation()
285 self.assertThat(ip, Equals("10.0.0.1"))
286
287 def test__avoids_gateway_ip(self):
288 # Note: 10.0.0.0/30 --> 10.0.0.1 and 10.0.0.0.2 are usable.
289 subnet = factory.make_Subnet(
290- cidr="10.0.0.0/30", gateway_ip="10.0.0.1", dns_servers=None)
291+ cidr="10.0.0.0/30", gateway_ip="10.0.0.1", dns_servers=None,
292+ managed=self.managed)
293+ if not self.managed:
294+ factory.make_IPRange(
295+ subnet, start_ip='10.0.0.1', end_ip='10.0.0.2',
296+ type=IPRANGE_TYPE.RESERVED)
297+ subnet = reload_object(subnet)
298 ip = subnet.get_next_ip_for_allocation()
299 self.assertThat(ip, Equals("10.0.0.2"))
300
301 def test__avoids_excluded_addresses(self):
302 # Note: 10.0.0.0/30 --> 10.0.0.1 and 10.0.0.0.2 are usable.
303 subnet = factory.make_Subnet(
304- cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None)
305+ cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None,
306+ managed=self.managed)
307+ if not self.managed:
308+ factory.make_IPRange(
309+ subnet, start_ip='10.0.0.1', end_ip='10.0.0.2',
310+ type=IPRANGE_TYPE.RESERVED)
311+ subnet = reload_object(subnet)
312 ip = subnet.get_next_ip_for_allocation(exclude_addresses=["10.0.0.1"])
313 self.assertThat(ip, Equals("10.0.0.2"))
314
315 def test__avoids_dns_servers(self):
316 # Note: 10.0.0.0/30 --> 10.0.0.1 and 10.0.0.0.2 are usable.
317 subnet = factory.make_Subnet(
318- cidr="10.0.0.0/30", gateway_ip=None, dns_servers=["10.0.0.1"])
319+ cidr="10.0.0.0/30", gateway_ip=None, dns_servers=["10.0.0.1"],
320+ managed=self.managed)
321+ if not self.managed:
322+ factory.make_IPRange(
323+ subnet, start_ip='10.0.0.1', end_ip='10.0.0.2',
324+ type=IPRANGE_TYPE.RESERVED)
325+ subnet = reload_object(subnet)
326 ip = subnet.get_next_ip_for_allocation()
327 self.assertThat(ip, Equals("10.0.0.2"))
328
329 def test__avoids_observed_neighbours(self):
330 # Note: 10.0.0.0/30 --> 10.0.0.1 and 10.0.0.0.2 are usable.
331 subnet = factory.make_Subnet(
332- cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None)
333+ cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None,
334+ managed=self.managed)
335+ if not self.managed:
336+ factory.make_IPRange(
337+ subnet, start_ip='10.0.0.1', end_ip='10.0.0.2',
338+ type=IPRANGE_TYPE.RESERVED)
339+ subnet = reload_object(subnet)
340 rackif = factory.make_Interface(vlan=subnet.vlan)
341 factory.make_Discovery(ip="10.0.0.1", interface=rackif)
342 ip = subnet.get_next_ip_for_allocation()
343@@ -952,7 +988,13 @@
344 def test__logs_if_suggests_previously_observed_neighbour(self):
345 # Note: 10.0.0.0/30 --> 10.0.0.1 and 10.0.0.0.2 are usable.
346 subnet = factory.make_Subnet(
347- cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None)
348+ cidr="10.0.0.0/30", gateway_ip=None, dns_servers=None,
349+ managed=self.managed)
350+ if not self.managed:
351+ factory.make_IPRange(
352+ subnet, start_ip='10.0.0.1', end_ip='10.0.0.2',
353+ type=IPRANGE_TYPE.RESERVED)
354+ subnet = reload_object(subnet)
355 rackif = factory.make_Interface(vlan=subnet.vlan)
356 now = datetime.now()
357 yesterday = now - timedelta(days=1)
358@@ -970,7 +1012,13 @@
359 def test__uses_smallest_free_range_when_not_considering_neighbours(self):
360 # Note: 10.0.0.0/29 --> 10.0.0.1 through 10.0.0.0.6 are usable.
361 subnet = factory.make_Subnet(
362- cidr="10.0.0.0/29", gateway_ip=None, dns_servers=None)
363+ cidr="10.0.0.0/29", gateway_ip=None, dns_servers=None,
364+ managed=self.managed)
365+ if not self.managed:
366+ factory.make_IPRange(
367+ subnet, start_ip='10.0.0.1', end_ip='10.0.0.6',
368+ type=IPRANGE_TYPE.RESERVED)
369+ subnet = reload_object(subnet)
370 # With .4 in use, the free ranges are {1, 2, 3}, {5, 6}. So MAAS should
371 # select 10.0.0.5, since that is the first address in the smallest
372 # available range.
373
374=== modified file 'src/maasserver/testing/factory.py'
375--- src/maasserver/testing/factory.py 2016-11-11 17:21:57 +0000
376+++ src/maasserver/testing/factory.py 2016-11-29 18:04:20 +0000
377@@ -848,7 +848,8 @@
378 def make_Subnet(self, name=None, vlan=None, space=None, cidr=None,
379 gateway_ip=RANDOM, dns_servers=None, host_bits=None,
380 fabric=None, vid=None, dhcp_on=False, version=None,
381- rdns_mode=RDNS_MODE.DEFAULT, allow_proxy=True):
382+ rdns_mode=RDNS_MODE.DEFAULT, allow_proxy=True,
383+ managed=True):
384 if name is None:
385 name = factory.make_name('name')
386 if vlan is None:
387@@ -869,7 +870,7 @@
388 subnet = Subnet(
389 name=name, vlan=vlan, cidr=cidr, gateway_ip=gateway_ip,
390 space=space, dns_servers=dns_servers, rdns_mode=rdns_mode,
391- allow_proxy=allow_proxy)
392+ allow_proxy=allow_proxy, managed=managed)
393 subnet.save()
394 return subnet
395
396
397=== modified file 'src/provisioningserver/utils/network.py'
398--- src/provisioningserver/utils/network.py 2016-11-03 15:39:17 +0000
399+++ src/provisioningserver/utils/network.py 2016-11-29 18:04:20 +0000
400@@ -35,6 +35,7 @@
401 import struct
402 from typing import (
403 Iterable,
404+ Tuple,
405 List,
406 Optional,
407 TypeVar,
408@@ -100,6 +101,8 @@
409 GATEWAY_IP = 'gateway-ip'
410 DYNAMIC = 'dynamic'
411 PROPOSED_DYNAMIC = 'proposed-dynamic'
412+ UNMANAGED = 'unmanaged'
413+ NEIGHBOUR = 'neighbour'
414
415
416 class MAASIPRange(IPRange):
417@@ -152,31 +155,173 @@
418 return json
419
420
421-def _combine_overlapping_maasipranges(
422+def get_iprange_intersection(a: MAASIPRange, b: MAASIPRange):
423+ """Given two overlapping MAASIPRange objects, return the best possible
424+ combination of the two without any information loss.
425+ """
426+ # This ensures r0's start index is less than or equal to r1's start index.
427+ r0, r1 = sorted([a, b])
428+ if r0.first == r1.first and r0.last == r1.last:
429+ # Easy case: exact match.
430+ # +------------+
431+ # | r0 |
432+ # +------------+
433+ # +------------+
434+ # | r1 |
435+ # + +------------+
436+ # ================
437+ # +------------+
438+ # | x |
439+ # +------------+
440+ # purpose(x) = r0 | r1
441+ return (
442+ MAASIPRange(r0.first, r0.last, purpose=r0.purpose | r1. purpose),
443+ )
444+ elif r0.first == r1.first and r0.last > r1.last:
445+ # Note: if the start index is equal, larger ranges always sort before
446+ # smaller ranges.
447+ # +------------+
448+ # | r0 |
449+ # +------------+
450+ # +--------+
451+ # | r1 |
452+ # + +--------+
453+ # ================
454+ # +---+--------+
455+ # | x | y |
456+ # +---+--------+
457+ # purpose(x) = r0 | r1
458+ # purpose(y) = r0
459+ return (
460+ MAASIPRange(r0.first, r1.last, purpose=r0.purpose | r1.purpose),
461+ MAASIPRange(r1.last + 1, r0.last, purpose=r0.purpose),
462+ )
463+ elif r0.first < r1.first and r0.last == r1.last:
464+ # +------------+
465+ # | r0 |
466+ # +------------+
467+ # +--------+
468+ # | r1 |
469+ # + +--------+
470+ # ================
471+ # +---+--------+
472+ # | x | y |
473+ # +---+--------+
474+ # purpose(x) = r0
475+ # purpose(y) = r0 | r1
476+ return (
477+ MAASIPRange(r0.first, r1.first - 1, purpose=r0.purpose),
478+ MAASIPRange(r1.first, r0.last, purpose=r0.purpose | r1.purpose),
479+ )
480+ elif r0.first < r1.first and r0.last < r1.last:
481+ # +--------+
482+ # | r0 |
483+ # +--------+
484+ # +--------+
485+ # | r1 |
486+ # + +--------+
487+ # ================
488+ # +---+----+---+
489+ # | x | y | z |
490+ # +---+----+---+
491+ # purpose(x) = r0
492+ # purpose(y) = r0 | r1
493+ # purpose(z) = r1
494+ return (
495+ MAASIPRange(r0.first, r1.first - 1, purpose=r0.purpose),
496+ MAASIPRange(
497+ r1.first, r0.last, purpose=r0.purpose | r1.purpose),
498+ MAASIPRange(r0.last + 1, r1.last, purpose=r1.purpose),
499+ )
500+ elif r0.first < r1.first and r0.last > r1.last:
501+ # +------------+
502+ # | r0 |
503+ # +------------+
504+ # +----+
505+ # | r1 |
506+ # + +----+
507+ # ================
508+ # +---+----+---+
509+ # | x | y | z |
510+ # +---+----+---+
511+ # x = r0
512+ # y = r0 | r1
513+ # z = r0
514+ return (
515+ MAASIPRange(r0.first, r1.first - 1, purpose=r0.purpose),
516+ MAASIPRange(
517+ r1.first, r1.last, purpose=r0.purpose | r1.purpose),
518+ MAASIPRange(r1.last + 1, r0.last, purpose=r0.purpose),
519+ )
520+ else:
521+ raise ValueError(
522+ "Unable to combine IP ranges: (%s, %s); do they actually overlap?"
523+ % (a, b))
524+
525+
526+def _split_overlapping_ipranges_for_mismatched_purposes(
527 ranges: Iterable[MAASIPRange]) -> List[MAASIPRange]:
528 """Returns the specified ranges after combining any overlapping ranges.
529
530 Given a sorted list of `MAASIPRange` objects, returns a new (sorted)
531 list where any adjacent overlapping ranges have been combined into a single
532 range.
533+
534+ Must be run after combining overlapping IP ranges (if purpose matches).
535 """
536 new_ranges = []
537 previous_min = None
538 previous_max = None
539+ previous_purpose = None
540 for item in ranges:
541 if previous_min is not None and previous_max is not None:
542 # Check for an overlapping range.
543 min_overlaps = previous_min <= item.first <= previous_max
544 max_overlaps = previous_min <= item.last <= previous_max
545- if min_overlaps or max_overlaps:
546+ if previous_purpose != item.purpose and (
547+ min_overlaps or max_overlaps):
548+ # Replace the previous range with one or more ranges.
549 previous = new_ranges.pop()
550+ ranges = get_iprange_intersection(previous, item)
551+ new_ranges.extend(ranges)
552+ item = new_ranges[-1]
553+ else:
554+ new_ranges.append(item)
555+ previous_min = item.first
556+ previous_max = item.last
557+ previous_purpose = item.purpose
558+ return new_ranges
559+
560+
561+def _combine_adjacent_maasipranges(
562+ ranges: Iterable[MAASIPRange]) -> List[MAASIPRange]:
563+ """Returns the specified ranges after combining any overlapping ranges.
564+
565+ Given a sorted list of `MAASIPRange` objects, returns a new (sorted)
566+ list where any adjacent overlapping ranges have been combined into a single
567+ range.
568+ """
569+ new_ranges = []
570+ previous_min = None
571+ previous_max = None
572+ previous_purpose = None
573+ for item in ranges:
574+ if previous_min is not None and previous_max is not None:
575+ # Check for an overlapping range.
576+ min_overlaps = previous_min <= item.first <= previous_max
577+ max_overlaps = previous_min <= item.last <= previous_max
578+ if item.purpose == previous_purpose and (
579+ min_overlaps or max_overlaps):
580+ # Replace the previous range with a new, combined range.
581+ new_ranges.pop()
582 item = make_iprange(
583 min(item.first, previous_min),
584 max(item.last, previous_max),
585- previous.purpose | item.purpose)
586+ item.purpose)
587+ new_ranges.append(item)
588 previous_min = item.first
589 previous_max = item.last
590- new_ranges.append(item)
591+ previous_purpose = item.purpose
592 return new_ranges
593
594
595@@ -398,8 +543,11 @@
596 (3) Combining adjacent ranges with an identical purpose.
597 """
598 self.ranges = _normalize_ipranges(self.ranges)
599- self.ranges = _combine_overlapping_maasipranges(self.ranges)
600 self.ranges = _coalesce_adjacent_purposes(self.ranges)
601+ self.ranges = _split_overlapping_ipranges_for_mismatched_purposes(
602+ self.ranges)
603+ self.ranges = _combine_adjacent_maasipranges(
604+ self.ranges)
605
606 def __ior__(self, other):
607 """Return self |= other."""
608@@ -521,7 +669,7 @@
609
610 def get_unused_ranges(
611 self, outer_range: OuterRange,
612- comment=IPRANGE_TYPE.UNUSED) -> 'MAASIPSet':
613+ purpose=IPRANGE_TYPE.UNUSED) -> 'MAASIPSet':
614 """Calculates and returns a list of unused IP ranges, based on
615 the supplied range of desired addresses.
616
617@@ -557,7 +705,7 @@
618 # range.
619 if candidate_end - candidate_start >= 0:
620 unused_ranges.append(
621- make_iprange(candidate_start, candidate_end, comment))
622+ make_iprange(candidate_start, candidate_end, purpose))
623 candidate_start = used_range.last + 1
624 # Skip the broadcast address, if this is an IPv4 network
625 if type(outer_range) == IPNetwork:
626@@ -572,7 +720,7 @@
627 # of the range we're checking against.
628 if candidate_end - candidate_start >= 0:
629 unused_ranges.append(
630- make_iprange(candidate_start, candidate_end, comment))
631+ make_iprange(candidate_start, candidate_end, purpose))
632 return MAASIPSet(unused_ranges)
633
634 def get_full_range(self, outer_range):