Merge lp:~allenap/maas/ntp-prefer-nearby-servers--bug-1675095 into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Superseded
Proposed branch: lp:~allenap/maas/ntp-prefer-nearby-servers--bug-1675095
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 549 lines (+311/-61)
6 files modified
src/maasserver/dbviews.py (+19/-2)
src/maasserver/ntp.py (+5/-4)
src/maasserver/routablepairs.py (+12/-0)
src/maasserver/tests/test_dbviews.py (+91/-22)
src/maasserver/tests/test_ntp.py (+54/-32)
src/maasserver/tests/test_routablepairs.py (+130/-1)
To merge this branch: bzr merge lp:~allenap/maas/ntp-prefer-nearby-servers--bug-1675095
Reviewer Review Type Date Requested Status
Andres Rodriguez (community) Needs Fixing
Review via email: mp+321267@code.launchpad.net

Commit message

Calculated routes between nodes are now prioritised.

Description of the change

This is necessarily hand-wavy because it's nearly impossible to anticipate the cost exactly. Instead a cost is calculated based on a few expectations:

- Sending traffic within a single host is fastest.
- Sending traffic within the same subnet is next.
- Then, sending traffic within the same VLAN.
- Then, sending traffic within the same space.
- Then, sending traffic within the null space.

These costs are relative to one another only, and they do not convey relative _magnitudes_ of cost. They are useful for ordering only.

To post a comment you must log in.
Revision history for this message
Andres Rodriguez (andreserl) wrote :
review: Needs Fixing

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/dbviews.py'
2--- src/maasserver/dbviews.py 2017-03-07 16:26:47 +0000
3+++ src/maasserver/dbviews.py 2017-03-29 15:49:17 +0000
4@@ -36,6 +36,7 @@
5 with closing(connection.cursor()) as cursor:
6 cursor.execute(view_sql)
7
8+
9 # Note that the `Discovery` model object is backed by this view. Any
10 # changes made to this view should be reflected there.
11 maasserver_discovery = dedent("""\
12@@ -102,17 +103,33 @@
13 # should not be used without constraining, say, the sets of nodes, to find
14 # addresses that are mutually routable between region controllers for example.
15 maasserver_routable_pairs = dedent("""\
16- SELECT if_left.node_id AS left_node_id,
17+ SELECT
18+ -- "Left" node.
19+ if_left.node_id AS left_node_id,
20 if_left.id AS left_interface_id,
21 subnet_left.id AS left_subnet_id,
22 vlan_left.id AS left_vlan_id,
23 sip_left.ip AS left_ip,
24+
25+ -- "Right" node.
26 if_right.node_id AS right_node_id,
27 if_right.id AS right_interface_id,
28 subnet_right.id AS right_subnet_id,
29 vlan_right.id AS right_vlan_id,
30 sip_right.ip AS right_ip,
31- vlan_left.space_id AS space_id
32+
33+ -- Space that left and right have in commmon. Can be NULL.
34+ vlan_left.space_id AS space_id,
35+
36+ -- Relative metric; lower is better.
37+ CASE
38+ WHEN if_left.node_id = if_right.node_id THEN 0
39+ WHEN subnet_left.id = subnet_right.id THEN 1
40+ WHEN vlan_left.id = vlan_right.id THEN 2
41+ WHEN vlan_left.space_id IS NOT NULL THEN 3
42+ ELSE 4 -- The NULL space.
43+ END AS metric
44+
45 FROM maasserver_interface AS if_left
46 JOIN maasserver_interface_ip_addresses AS ifia_left
47 ON if_left.id = ifia_left.interface_id
48
49=== modified file 'src/maasserver/ntp.py'
50--- src/maasserver/ntp.py 2016-10-03 20:10:53 +0000
51+++ src/maasserver/ntp.py 2017-03-29 15:49:17 +0000
52@@ -124,8 +124,9 @@
53 routable_addrs_map: AddressMap) -> Iterable[IPAddress]:
54 """Choose one routable address per destination node.
55
56- The result is stable, preferring IPv6 over IPv4, and then the highest
57- address numberically. (In fact it relies on the ordering provided by
58- `netaddr.IPAddress`, which is basically that.)
59+ The addresses are in preference order (see `find_addresses_between_nodes`
60+ for information on how that's derived) so this simply chooses the first.
61 """
62- return map(max, routable_addrs_map.values())
63+ for addresses in routable_addrs_map.values():
64+ assert len(addresses) >= 1
65+ yield addresses[0]
66
67=== modified file 'src/maasserver/routablepairs.py'
68--- src/maasserver/routablepairs.py 2016-09-19 14:50:27 +0000
69+++ src/maasserver/routablepairs.py 2017-03-29 15:49:17 +0000
70@@ -24,6 +24,17 @@
71 ``node-left`` and ``node-right`` are one of the nodes passed in and
72 ``addr-left`` and ``addr-right`` are :class:`netaddr.IPAddress` instances
73 corresponding to an active address on their respective nodes.
74+
75+ The results are sorted, lowest metric first, meaning that earlier
76+ addresses are likely to have a lower overall communications cost, lower
77+ latency, and so on. The order of preference is:
78+
79+ - Same node
80+ - Same subnet
81+ - Same VLAN
82+ - Same space
83+
84+ An explicitly defined space is preferred to the default / null space.
85 """
86 nodes_left = {node.id: node for node in nodes_left}
87 nodes_right = {node.id: node for node in nodes_right}
88@@ -48,6 +59,7 @@
89 FROM maasserver_routable_pairs
90 WHERE left_node_id IN (%s)
91 AND right_node_id IN (%s)
92+ ORDER BY metric ASC
93 """)
94
95
96
97=== modified file 'src/maasserver/tests/test_dbviews.py'
98--- src/maasserver/tests/test_dbviews.py 2017-01-28 00:51:47 +0000
99+++ src/maasserver/tests/test_dbviews.py 2017-03-29 15:49:17 +0000
100@@ -10,6 +10,7 @@
101 _ALL_VIEWS,
102 register_all_views,
103 )
104+from maasserver.models.subnet import Subnet
105 from maasserver.testing.factory import factory
106 from maasserver.testing.testcase import MAASServerTestCase
107 from testtools.matchers import HasLength
108@@ -39,29 +40,97 @@
109 cursor.execute("SELECT * from maasserver_routable_pairs")
110 self.assertThat(cursor.fetchall(), HasLength(0))
111
112- def make_node_with_address(self, space, cidr):
113+ def make_node_with_address(self, cidr, space=None, vlan=None):
114 node = factory.make_Node()
115 iface = factory.make_Interface(node=node)
116- subnet = factory.make_Subnet(space=space, cidr=cidr)
117+ try:
118+ subnet = Subnet.objects.get(cidr=cidr, vlan__space=space)
119+ except Subnet.DoesNotExist:
120+ subnet = factory.make_Subnet(cidr=cidr, space=space, vlan=vlan)
121 sip = factory.make_StaticIPAddress(interface=iface, subnet=subnet)
122 return node, iface, subnet, sip
123
124+ def test__contains_routes_between_nodes_on_same_subnet(self):
125+ network = factory.make_ip4_or_6_network()
126+ node1, if1, sn1, sip1 = self.make_node_with_address(network)
127+ node2, if2, sn2, sip2 = self.make_node_with_address(network)
128+
129+ # Routes between all addresses are found, even back to themselves.
130+ left = node1.id, if1.id, sn1.id, sn1.vlan.id, sip1.ip
131+ right = node2.id, if2.id, sn2.id, sn2.vlan.id, sip2.ip
132+ row = lambda ent1, ent2, metric: (*ent1, *ent2, None, metric)
133+ expected = [
134+ row(left, left, 0),
135+ row(left, right, 1), # Same space, hence metric of 3.
136+ row(right, right, 0),
137+ row(right, left, 1), # Same space, hence metric of 3.
138+ ]
139+
140+ with connection.cursor() as cursor:
141+ cursor.execute("SELECT * from maasserver_routable_pairs")
142+ self.assertItemsEqual(expected, cursor.fetchall())
143+
144+ def test__contains_routes_between_nodes_on_same_vlan(self):
145+ vlan = factory.make_VLAN()
146+ network1 = factory.make_ip4_or_6_network()
147+ network2 = factory.make_ip4_or_6_network(version=network1.version)
148+ node1, if1, sn1, sip1 = self.make_node_with_address(
149+ network1, vlan=vlan)
150+ node2, if2, sn2, sip2 = self.make_node_with_address(
151+ network2, vlan=vlan)
152+
153+ # Routes between all addresses are found, even back to themselves.
154+ left = node1.id, if1.id, sn1.id, sn1.vlan.id, sip1.ip
155+ right = node2.id, if2.id, sn2.id, sn2.vlan.id, sip2.ip
156+ row = lambda ent1, ent2, metric: (*ent1, *ent2, None, metric)
157+ expected = [
158+ row(left, left, 0),
159+ row(left, right, 2), # Same VLAN, hence metric of 2.
160+ row(right, right, 0),
161+ row(right, left, 2), # Same VLAN, hence metric of 2.
162+ ]
163+
164+ with connection.cursor() as cursor:
165+ cursor.execute("SELECT * from maasserver_routable_pairs")
166+ self.assertItemsEqual(expected, cursor.fetchall())
167+
168 def test__contains_routes_between_nodes_on_same_space(self):
169 space = factory.make_Space()
170 network1 = factory.make_ip4_or_6_network()
171 network2 = factory.make_ip4_or_6_network(version=network1.version)
172- node1, if1, sn1, sip1 = self.make_node_with_address(space, network1)
173- node2, if2, sn2, sip2 = self.make_node_with_address(space, network2)
174-
175- # Routes between all addresses are found, even back to themselves.
176- left = node1.id, if1.id, sn1.id, sn1.vlan.id, sip1.ip
177- right = node2.id, if2.id, sn2.id, sn2.vlan.id, sip2.ip
178- row = lambda ent1, ent2: ent1 + ent2 + (space.id, )
179- expected = [
180- row(left, left),
181- row(left, right),
182- row(right, right),
183- row(right, left),
184+ node1, if1, sn1, sip1 = self.make_node_with_address(network1, space)
185+ node2, if2, sn2, sip2 = self.make_node_with_address(network2, space)
186+
187+ # Routes between all addresses are found, even back to themselves.
188+ left = node1.id, if1.id, sn1.id, sn1.vlan.id, sip1.ip
189+ right = node2.id, if2.id, sn2.id, sn2.vlan.id, sip2.ip
190+ row = lambda ent1, ent2, metric: (*ent1, *ent2, space.id, metric)
191+ expected = [
192+ row(left, left, 0),
193+ row(left, right, 3), # Same space, hence metric of 3.
194+ row(right, right, 0),
195+ row(right, left, 3), # Same space, hence metric of 3.
196+ ]
197+
198+ with connection.cursor() as cursor:
199+ cursor.execute("SELECT * from maasserver_routable_pairs")
200+ self.assertItemsEqual(expected, cursor.fetchall())
201+
202+ def test__contains_routes_between_nodes_via_null_space(self):
203+ network1 = factory.make_ip4_or_6_network()
204+ network2 = factory.make_ip4_or_6_network(version=network1.version)
205+ node1, if1, sn1, sip1 = self.make_node_with_address(network1)
206+ node2, if2, sn2, sip2 = self.make_node_with_address(network2)
207+
208+ # Routes between all addresses are found, even back to themselves.
209+ left = node1.id, if1.id, sn1.id, sn1.vlan.id, sip1.ip
210+ right = node2.id, if2.id, sn2.id, sn2.vlan.id, sip2.ip
211+ row = lambda ent1, ent2, metric: (*ent1, *ent2, None, metric)
212+ expected = [
213+ row(left, left, 0),
214+ row(left, right, 4), # The NULL space, hence metric of 4.
215+ row(right, right, 0),
216+ row(right, left, 4), # The NULL space, hence metric of 4.
217 ]
218
219 with connection.cursor() as cursor:
220@@ -73,8 +142,8 @@
221 space2 = factory.make_Space()
222 network1 = factory.make_ip4_or_6_network()
223 network2 = factory.make_ip4_or_6_network(version=network1.version)
224- node1, if1, sn1, sip1 = self.make_node_with_address(space1, network1)
225- node2, if2, sn2, sip2 = self.make_node_with_address(space2, network2)
226+ node1, if1, sn1, sip1 = self.make_node_with_address(network1, space1)
227+ node2, if2, sn2, sip2 = self.make_node_with_address(network2, space2)
228
229 # Only routes from left to left and right to right are found: right is
230 # not routable from left, and left is not routable from right because
231@@ -82,8 +151,8 @@
232 left = node1.id, if1.id, sn1.id, sn1.vlan.id, sip1.ip
233 right = node2.id, if2.id, sn2.id, sn2.vlan.id, sip2.ip
234 expected = [
235- left + left + (space1.id,),
236- right + right + (space2.id,),
237+ (*left, *left, space1.id, 0),
238+ (*right, *right, space2.id, 0),
239 ]
240
241 with connection.cursor() as cursor:
242@@ -95,8 +164,8 @@
243 network1 = factory.make_ip4_or_6_network()
244 network2 = factory.make_ip4_or_6_network(
245 version=(4 if network1.version == 6 else 6))
246- node1, if1, sn1, sip1 = self.make_node_with_address(space, network1)
247- node2, if2, sn2, sip2 = self.make_node_with_address(space, network2)
248+ node1, if1, sn1, sip1 = self.make_node_with_address(network1, space)
249+ node2, if2, sn2, sip2 = self.make_node_with_address(network2, space)
250
251 # Only routes from left to left and right to right are found: right is
252 # not routable from left, and left is not routable from right because
253@@ -104,8 +173,8 @@
254 left = node1.id, if1.id, sn1.id, sn1.vlan.id, sip1.ip
255 right = node2.id, if2.id, sn2.id, sn2.vlan.id, sip2.ip
256 expected = [
257- left + left + (space.id,),
258- right + right + (space.id,),
259+ (*left, *left, space.id, 0),
260+ (*right, *right, space.id, 0),
261 ]
262
263 with connection.cursor() as cursor:
264
265=== modified file 'src/maasserver/tests/test_ntp.py'
266--- src/maasserver/tests/test_ntp.py 2017-01-28 00:51:47 +0000
267+++ src/maasserver/tests/test_ntp.py 2017-03-29 15:49:17 +0000
268@@ -5,8 +5,6 @@
269
270 __all__ = []
271
272-from operator import methodcaller
273-
274 from maasserver.models.config import Config
275 from maasserver.ntp import (
276 get_peers_for,
277@@ -14,10 +12,12 @@
278 )
279 from maasserver.testing.factory import factory
280 from maasserver.testing.testcase import MAASServerTestCase
281-from netaddr import IPAddress
282+from netaddr import (
283+ IPAddress,
284+ IPSet,
285+)
286 from testtools.matchers import (
287 AfterPreprocessing,
288- AllMatch,
289 ContainsAll,
290 Equals,
291 HasLength,
292@@ -231,7 +231,7 @@
293
294 For racks, machines, and devices, a selection process takes place to
295 determine which of several candidate addresses per server to choose. This
296- result is stable, i.e. it will choose the same address each time.
297+ result is semi-stable, i.e. it will always prefer "closer" addresses.
298 """
299
300 scenarios = (
301@@ -253,23 +253,31 @@
302 super(TestGetServersFor_Selection, self).setUp()
303 Config.objects.set_config("ntp_external_only", False)
304
305- def test_prefers_ipv6_to_ipv4_peers_then_highest_numerically(self):
306+ def test_prefers_closest_addresses(self):
307 subnet4 = factory.make_Subnet(version=4)
308- subnet6a = factory.make_Subnet(version=6)
309- subnet6b = factory.make_Subnet(version=6)
310- # Ensure that addresses in subnet6a < those in subnet6b.
311- subnet6a, subnet6b = sorted(
312- {subnet6a, subnet6b}, key=methodcaller("get_ipnetwork"))
313- # Create a node and server with an address in each subnet.
314- node, server = self.make_node(), self.make_server()
315- subnets = {subnet4, subnet6a, subnet6b}
316- populate_node_with_addresses(node, subnets)
317- populate_node_with_addresses(server, subnets)
318+ subnet6 = factory.make_Subnet(version=6)
319+ # Separate subnets but sharing the VLAN, hence routable.
320+ subnet4v = factory.make_Subnet(version=4, vlan=subnet4.vlan)
321+ subnet6v = factory.make_Subnet(version=6, vlan=subnet6.vlan)
322+
323+ # Create a node with an address in the first two subnets...
324+ node = self.make_node()
325+ populate_node_with_addresses(node, {subnet4, subnet6})
326+ # ... and a server with an address in every subnet.
327+ server = self.make_server()
328+ populate_node_with_addresses(
329+ server, {subnet4, subnet6, subnet4v, subnet6v})
330+
331+ # The NTP server addresses chosen will be those that are "closest" to
332+ # the node, and same-subnet wins in this over same-VLAN. No additional
333+ # preference is made between IPv4 or IPv6, hence we allow for either.
334+ preferred_subnets = subnet4, subnet6
335+ preferred_networks = IPSet(
336+ subnet.get_ipnetwork() for subnet in preferred_subnets)
337
338 servers = get_servers_for(node)
339 self.assertThat(servers, Not(HasLength(0)))
340- self.assertThat(servers, AllMatch(IsIPv6Address))
341- self.assertThat(subnet6b.get_ipnetwork(), ContainsAll(servers))
342+ self.assertThat(preferred_networks, ContainsAll(servers))
343
344
345 class TestGetPeersFor_Region_RegionRack(MAASServerTestCase):
346@@ -296,23 +304,37 @@
347 get_peers_for(node2),
348 IsSetOfServers({node1_address.ip}))
349
350- def test_prefers_ipv6_to_ipv4_peers_then_highest_numerically(self):
351+ def test_prefers_closest_addresses(self):
352 subnet4 = factory.make_Subnet(version=4)
353- subnet6a = factory.make_Subnet(version=6)
354- subnet6b = factory.make_Subnet(version=6)
355- # Ensure that addresses in subnet6a < those in subnet6b.
356- subnet6a, subnet6b = sorted(
357- {subnet6a, subnet6b}, key=methodcaller("get_ipnetwork"))
358- # Create some peers, each with an address in each subnet.
359- nodes = self.make_node(), self.make_node()
360- subnets = {subnet4, subnet6a, subnet6b}
361- for node in nodes:
362- populate_node_with_addresses(node, subnets)
363- for node in nodes:
364+ subnet6 = factory.make_Subnet(version=6)
365+ # Separate subnets but sharing the VLAN, hence routable.
366+ subnet4v1 = factory.make_Subnet(version=4, vlan=subnet4.vlan)
367+ subnet6v1 = factory.make_Subnet(version=6, vlan=subnet6.vlan)
368+ subnet4v2 = factory.make_Subnet(version=4, vlan=subnet4.vlan)
369+ subnet6v2 = factory.make_Subnet(version=6, vlan=subnet6.vlan)
370+
371+ # Create a node with an address in the first two subnets and the first
372+ # two same-VLAN subnets.
373+ node1 = self.make_node()
374+ populate_node_with_addresses(
375+ node1, {subnet4, subnet6, subnet4v1, subnet6v1})
376+ # Create a node with an address in the first two subnets and the
377+ # second two same-VLAN subnets.
378+ node2 = self.make_node()
379+ populate_node_with_addresses(
380+ node2, {subnet4, subnet6, subnet4v2, subnet6v2})
381+
382+ # The NTP server addresses chosen will be those that are "closest" to
383+ # the node, and same-subnet wins in this over same-VLAN. No additional
384+ # preference is made between IPv4 or IPv6, hence we allow for either.
385+ preferred_subnets = subnet4, subnet6
386+ preferred_networks = IPSet(
387+ subnet.get_ipnetwork() for subnet in preferred_subnets)
388+
389+ for node in (node1, node2):
390 peers = get_peers_for(node)
391 self.assertThat(peers, Not(HasLength(0)))
392- self.assertThat(peers, AllMatch(IsIPv6Address))
393- self.assertThat(subnet6b.get_ipnetwork(), ContainsAll(peers))
394+ self.assertThat(preferred_networks, ContainsAll(peers))
395
396
397 class TestGetPeersFor_Other(MAASServerTestCase):
398
399=== modified file 'src/maasserver/tests/test_routablepairs.py'
400--- src/maasserver/tests/test_routablepairs.py 2017-01-28 00:51:47 +0000
401+++ src/maasserver/tests/test_routablepairs.py 2017-03-29 15:49:17 +0000
402@@ -5,13 +5,21 @@
403
404 __all__ = []
405
406-from itertools import product
407+from itertools import (
408+ product,
409+ takewhile,
410+)
411+import random
412
413 from maasserver.models.node import Node
414 from maasserver.routablepairs import find_addresses_between_nodes
415 from maasserver.testing.factory import factory
416 from maasserver.testing.testcase import MAASServerTestCase
417 from testtools import ExpectedException
418+from testtools.matchers import (
419+ AfterPreprocessing,
420+ Equals,
421+)
422
423
424 class TestFindAddressesBetweenNodes(MAASServerTestCase):
425@@ -102,3 +110,124 @@
426 # No routable addresses are found.
427 self.assertItemsEqual(
428 expected, find_addresses_between_nodes([node1], [node2]))
429+
430+ def gen_disjoint_networks(self):
431+ """Generate disjoint networks.
432+
433+ Can be IPv4 or IPv6, but once generation has begun they'll all be the
434+ same family.
435+ """
436+ make_network = random.choice([
437+ factory.make_ipv4_network,
438+ factory.make_ipv6_network]
439+ )
440+ networks = []
441+ while True:
442+ network = make_network(disjoint_from=networks)
443+ networks.append(network)
444+ yield network
445+
446+ def test__yields_routes_with_lowest_metrics_first(self):
447+ space = factory.make_Space()
448+ # Ensure networks are disjoint but of the same family.
449+ networks = self.gen_disjoint_networks()
450+
451+ # Create the node for the "left" that has two IP addresses, one in the
452+ # null space, one in a non-null space.
453+ origin = factory.make_Node(hostname="origin")
454+ origin_iface = factory.make_Interface(
455+ node=origin, disconnected=True)
456+ origin_subnet = factory.make_Subnet(
457+ space=space, cidr=next(networks))
458+ origin_subnet_null_space = factory.make_Subnet(
459+ space=None, cidr=next(networks))
460+ origin_sip = factory.make_StaticIPAddress(
461+ interface=origin_iface, subnet=origin_subnet)
462+ origin_sip_null_space = factory.make_StaticIPAddress(
463+ interface=origin_iface, subnet=origin_subnet_null_space)
464+
465+ # Same subnet, different node.
466+ node_same_subnet = factory.make_Node(hostname="same-subnet")
467+ sip_same_subnet = factory.make_StaticIPAddress(
468+ interface=factory.make_Interface(
469+ node=node_same_subnet, disconnected=True),
470+ subnet=origin_subnet)
471+
472+ # Same VLAN, different subnet, different node.
473+ node_same_vlan = factory.make_Node(hostname="same-vlan")
474+ sip_same_vlan = factory.make_StaticIPAddress(
475+ interface=factory.make_Interface(
476+ node=node_same_vlan, disconnected=True),
477+ subnet=factory.make_Subnet(
478+ space=space, vlan=origin_subnet.vlan,
479+ cidr=next(networks)))
480+
481+ # Same space, different VLAN, subnet, and node.
482+ node_same_space = factory.make_Node(hostname="same-space")
483+ sip_same_space = factory.make_StaticIPAddress(
484+ interface=factory.make_Interface(
485+ node=node_same_space, disconnected=True),
486+ subnet=factory.make_Subnet(
487+ space=space, cidr=next(networks)))
488+
489+ # Null space, different VLAN, subnet, and node.
490+ node_null_space = factory.make_Node(hostname="null-space")
491+ sip_null_space = factory.make_StaticIPAddress(
492+ interface=factory.make_Interface(
493+ node=node_null_space, disconnected=True),
494+ subnet=factory.make_Subnet(
495+ space=None, cidr=next(networks)))
496+
497+ # We'll search for routes between `lefts` and `rights`.
498+ lefts = [
499+ origin,
500+ ]
501+ rights = [
502+ node_same_subnet,
503+ node_same_vlan,
504+ node_same_space,
505+ node_null_space,
506+ ]
507+
508+ # This is in order, lowest "metric" first.
509+ expected = [
510+ (origin, origin_sip.get_ipaddress(),
511+ node_same_subnet, sip_same_subnet.get_ipaddress()),
512+ (origin, origin_sip.get_ipaddress(),
513+ node_same_vlan, sip_same_vlan.get_ipaddress()),
514+ (origin, origin_sip.get_ipaddress(),
515+ node_same_space, sip_same_space.get_ipaddress()),
516+ (origin, origin_sip_null_space.get_ipaddress(),
517+ node_null_space, sip_null_space.get_ipaddress()),
518+ ]
519+ self.assertThat(
520+ find_addresses_between_nodes(lefts, rights),
521+ AfterPreprocessing(list, Equals(expected)))
522+
523+ # Same node, same space, different VLAN and subnet. We did not add
524+ # this earlier because its existence allows for a large number of
525+ # additional routes between the origin and the other nodes, which
526+ # would have obscured the test.
527+ origin_sip_2 = factory.make_StaticIPAddress(
528+ interface=factory.make_Interface(
529+ node=origin, disconnected=True),
530+ subnet=factory.make_Subnet(
531+ space=space, cidr=next(networks)))
532+
533+ # Now the first addresses returned are between those addresses we
534+ # created on the same node, in no particular order.
535+ origin_ips = origin_sip.get_ipaddress(), origin_sip_2.get_ipaddress()
536+ expected_mutual = {
537+ (origin, ip1, origin, ip2)
538+ for ip1, ip2 in product(origin_ips, origin_ips)
539+ }
540+ # There's a mutual route for the null-space IP address too.
541+ expected_mutual.add(
542+ (origin, origin_sip_null_space.get_ipaddress(),
543+ origin, origin_sip_null_space.get_ipaddress()))
544+ observed_mutual = takewhile(
545+ (lambda route: route[0] == route[2]), # Route is mutual.
546+ find_addresses_between_nodes(lefts, [origin, *rights]),
547+ )
548+ self.assertItemsEqual(
549+ expected_mutual, observed_mutual)