Merge lp:~mpontillo/maas/rdns-service--bug-1633822--part2 into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 5506
Proposed branch: lp:~mpontillo/maas/rdns-service--bug-1633822--part2
Merge into: lp:~maas-committers/maas/trunk
Prerequisite: lp:~mpontillo/maas/rdns-model--bug-1633822--part1
Diff against target: 757 lines (+599/-2)
8 files modified
src/maasserver/eventloop.py (+12/-0)
src/maasserver/regiondservices/reverse_dns.py (+99/-0)
src/maasserver/regiondservices/tests/test_reverse_dns.py (+111/-0)
src/maasserver/tests/test_plugin.py (+1/-0)
src/maasserver/triggers/tests/test_websocket.py (+3/-0)
src/maasserver/triggers/websocket.py (+17/-0)
src/provisioningserver/utils/network.py (+100/-1)
src/provisioningserver/utils/tests/test_network.py (+256/-1)
To merge this branch: bzr merge lp:~mpontillo/maas/rdns-service--bug-1633822--part2
Reviewer Review Type Date Requested Status
Lee Trager (community) Approve
Review via email: mp+308776@code.launchpad.net

Commit message

Store reverse-DNS lookups in the database whenever neighbours are updated.

 * Add trigger boilerplate so we can register with the listener for
   changes to the neighbour table.
 * Add ReverseDNSService to listen for neighbour changes.
 * Add utilities to reverse-resolve IP addresses, and sort the results.

To post a comment you must log in.
Revision history for this message
Lee Trager (ltrager) wrote :

LGTM!

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

Attempt to merge into lp:maas failed due to conflicts:

text conflict in src/provisioningserver/utils/tests/test_network.py

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/eventloop.py'
2--- src/maasserver/eventloop.py 2016-09-23 03:32:14 +0000
3+++ src/maasserver/eventloop.py 2016-10-24 19:31:16 +0000
4@@ -143,6 +143,13 @@
5 return ActiveDiscoveryService(reactor, postgresListener)
6
7
8+def make_ReverseDNSService(postgresListener):
9+ from maasserver.regiondservices.reverse_dns import (
10+ ReverseDNSService
11+ )
12+ return ReverseDNSService(postgresListener)
13+
14+
15 def make_NetworkTimeProtocolService():
16 from maasserver.regiondservices import ntp
17 return ntp.RegionNetworkTimeProtocolService(reactor)
18@@ -269,6 +276,11 @@
19 "factory": make_ActiveDiscoveryService,
20 "requires": ["postgres-listener"],
21 },
22+ "reverse-dns": {
23+ "only_on_master": True,
24+ "factory": make_ReverseDNSService,
25+ "requires": ["postgres-listener"],
26+ },
27 "rack-controller": {
28 "only_on_master": False,
29 "factory": make_RackControllerService,
30
31=== added file 'src/maasserver/regiondservices/reverse_dns.py'
32--- src/maasserver/regiondservices/reverse_dns.py 1970-01-01 00:00:00 +0000
33+++ src/maasserver/regiondservices/reverse_dns.py 2016-10-24 19:31:16 +0000
34@@ -0,0 +1,99 @@
35+# Copyright 2016 Canonical Ltd. This software is licensed under the
36+# GNU Affero General Public License version 3 (see the file LICENSE).
37+
38+"""Reverse DNS service."""
39+
40+__all__ = [
41+ "ReverseDNSService"
42+]
43+
44+from typing import List
45+
46+from maasserver.listener import PostgresListenerService
47+from maasserver.models import (
48+ RDNS,
49+ RegionController,
50+)
51+from maasserver.utils.threads import deferToDatabase
52+from provisioningserver.utils.network import reverseResolve
53+from twisted.application.service import Service
54+from twisted.internet.defer import inlineCallbacks
55+from twisted.python import log
56+
57+
58+class ReverseDNSService(Service):
59+ """Service to resolve and cache reverse DNS names for neighbour entries."""
60+
61+ def __init__(self, postgresListener: PostgresListenerService=None):
62+ super().__init__()
63+ self.listener = postgresListener
64+ # We will cache a reference to the region model object so we don't
65+ # need to look it up every time a DNS entry changes.
66+ self.region = None
67+
68+ @inlineCallbacks
69+ def startService(self):
70+ super().startService()
71+ self.region = yield deferToDatabase(
72+ RegionController.objects.get_running_controller)
73+ if self.listener is not None:
74+ self.listener.register('neighbour', self.consumeNeighbourEvent)
75+
76+ def stopService(self):
77+ if self.listener is not None:
78+ self.listener.unregister('neighbour', self.consumeNeighbourEvent)
79+ return super().stopService()
80+
81+ def set_rdns_entry(self, ip: str, results: List[str]):
82+ """Set the reverse-DNS entry for the specified IP address.
83+
84+ Must run in a thread where database access is permitted.
85+
86+ :param ip: the IP address to update.
87+ :param results: a non-empty list of hostnames for the specified IP,
88+ in "preferred" order.
89+ """
90+ RDNS.objects.set_current_entry(ip, results, self.region)
91+
92+ def delete_rdns_entry(self, ip: str):
93+ """Delete the reverse-DNS entry for the specified IP address.
94+
95+ Must run in a thread where database access is permitted.
96+
97+ :param ip: the IP address to delete.
98+ """
99+ RDNS.objects.delete_current_entry(ip, self.region)
100+
101+ @inlineCallbacks
102+ def consumeNeighbourEvent(self, action: str=None, cidr: str=None):
103+ """Given an event from the postgresListener, resolve RDNS for an IP.
104+
105+ This method is called when an observed neighbour is changed.
106+
107+ :param action: one of {'create', 'update', 'delete'}
108+ :param cidr: the 'ip' field in the neighbour table, after PostgreSQL
109+ casts it to a string. It will end up looking like "x.x.x.x/32"
110+ or "yyyy:yyyy::yyyy/128".
111+ """
112+ ip = cidr.split('/')[0] # Strip off the "/<prefixlen>".
113+ if action in ('create', 'update'):
114+ # XXX mpontillo 2016-10-19: We might consider throttling this on
115+ # a per-IP-address basis, both because multiple racks can observe
116+ # the same IP address, and because an IP address might repeatedly
117+ # go back-and-forth between two MACs in the case of a duplicate IP
118+ # address.
119+ results = yield reverseResolve(ip)
120+ if results is not None:
121+ if len(results) > 0:
122+ yield deferToDatabase(self.set_rdns_entry, ip, results)
123+ else:
124+ yield deferToDatabase(self.delete_rdns_entry, ip)
125+ else:
126+ # A return of 'None' indicates a timeout or other possibly-
127+ # temporary failure, so take no action.
128+ pass
129+ elif action == 'delete':
130+ yield deferToDatabase(self.delete_rdns_entry, ip)
131+ else:
132+ log.msg("Unsupported event from listener: action=%r, cidr=%r" % (
133+ action, cidr), system="reverse-dns")
134
135=== added file 'src/maasserver/regiondservices/tests/test_reverse_dns.py'
136--- src/maasserver/regiondservices/tests/test_reverse_dns.py 1970-01-01 00:00:00 +0000
137+++ src/maasserver/regiondservices/tests/test_reverse_dns.py 2016-10-24 19:31:16 +0000
138@@ -0,0 +1,111 @@
139+# Copyright 2016 Canonical Ltd. This software is licensed under the
140+# GNU Affero General Public License version 3 (see the file LICENSE).
141+
142+"""Tests for reverse-DNS service."""
143+
144+__all__ = []
145+
146+from unittest.mock import Mock
147+
148+from crochet import wait_for
149+from maasserver.models import RDNS
150+from maasserver.regiondservices import reverse_dns as reverse_dns_module
151+from maasserver.regiondservices.reverse_dns import ReverseDNSService
152+from maasserver.testing.factory import factory
153+from maasserver.testing.testcase import MAASTransactionServerTestCase
154+from maasserver.utils.threads import deferToDatabase
155+from maastesting.matchers import MockCalledOnceWith
156+from provisioningserver.utils.tests.test_network import (
157+ TestReverseResolveMixIn,
158+)
159+from testtools.matchers import (
160+ Equals,
161+ Is,
162+)
163+from twisted.internet.defer import inlineCallbacks
164+
165+
166+class TestReverseDNSService(
167+ TestReverseResolveMixIn, MAASTransactionServerTestCase):
168+ """Tests for `RegionNetworksMonitoringService`."""
169+
170+ def setUp(self):
171+ super().setUp()
172+ self.region = factory.make_RegionRackController()
173+ # This is so get_running_controller() works properly.
174+ RegionController = self.patch(reverse_dns_module, "RegionController")
175+ RegionController.objects = Mock()
176+ RegionController.objects.get_running_controller = Mock()
177+ RegionController.objects.get_running_controller.return_value = (
178+ self.region)
179+
180+ @wait_for(30)
181+ @inlineCallbacks
182+ def test__caches_region_model_object(self):
183+ hostname = factory.make_hostname()
184+ self.set_fake_twisted_dns_reply([hostname])
185+ service = ReverseDNSService()
186+ yield service.startService()
187+ self.assertThat(service.region, Equals(self.region))
188+ service.stopService()
189+
190+ @wait_for(30)
191+ @inlineCallbacks
192+ def test__adds_rdns_entry(self):
193+ hostname = factory.make_hostname()
194+ self.set_fake_twisted_dns_reply([hostname])
195+ service = ReverseDNSService()
196+ yield service.startService()
197+ ip = factory.make_ip_address(ipv6=False)
198+ yield service.consumeNeighbourEvent("create", "%s/32" % ip)
199+ service.stopService()
200+ result = yield deferToDatabase(RDNS.objects.first)
201+ self.assertThat(result.ip, Equals(ip))
202+ self.assertThat(result.hostname, Equals(hostname))
203+
204+ @wait_for(30)
205+ @inlineCallbacks
206+ def test__updates_rdns_entry(self):
207+ hostname = factory.make_hostname()
208+ hostname2 = factory.make_hostname()
209+ self.set_fake_twisted_dns_reply([hostname])
210+ service = ReverseDNSService()
211+ yield service.startService()
212+ ip = factory.make_ip_address(ipv6=False)
213+ yield service.consumeNeighbourEvent("create", "%s/32" % ip)
214+ self.set_fake_twisted_dns_reply([hostname2])
215+ yield service.consumeNeighbourEvent("update", "%s/32" % ip)
216+ service.stopService()
217+ result = yield deferToDatabase(RDNS.objects.first)
218+ self.assertThat(result.ip, Equals(ip))
219+ self.assertThat(result.hostname, Equals(hostname2))
220+
221+ @wait_for(30)
222+ @inlineCallbacks
223+ def test__deletes_rdns_entry(self):
224+ hostname = factory.make_hostname()
225+ self.set_fake_twisted_dns_reply([hostname])
226+ service = ReverseDNSService()
227+ yield service.startService()
228+ ip = factory.make_ip_address(ipv6=False)
229+ yield service.consumeNeighbourEvent("create", "%s/32" % ip)
230+ yield service.consumeNeighbourEvent("delete", "%s/32" % ip)
231+ service.stopService()
232+ result = yield deferToDatabase(RDNS.objects.first)
233+ self.assertThat(result, Is(None))
234+
235+ @wait_for(30)
236+ @inlineCallbacks
237+ def test__registers_and_unregisters_listener(self):
238+ listener = Mock()
239+ listener.register = Mock()
240+ listener.unregister = Mock()
241+ service = ReverseDNSService(postgresListener=listener)
242+ yield service.startService()
243+ self.assertThat(listener.register, MockCalledOnceWith(
244+ 'neighbour', service.consumeNeighbourEvent
245+ ))
246+ service.stopService()
247+ self.assertThat(listener.unregister, MockCalledOnceWith(
248+ 'neighbour', service.consumeNeighbourEvent
249+ ))
250
251=== modified file 'src/maasserver/tests/test_plugin.py'
252--- src/maasserver/tests/test_plugin.py 2016-10-19 21:08:54 +0000
253+++ src/maasserver/tests/test_plugin.py 2016-10-24 19:31:16 +0000
254@@ -103,6 +103,7 @@
255 "postgres-listener",
256 "rack-controller",
257 "region-controller",
258+ "reverse-dns",
259 "rpc",
260 "rpc-advertise",
261 "service-monitor",
262
263=== modified file 'src/maasserver/triggers/tests/test_websocket.py'
264--- src/maasserver/triggers/tests/test_websocket.py 2016-10-12 15:26:17 +0000
265+++ src/maasserver/triggers/tests/test_websocket.py 2016-10-24 19:31:16 +0000
266@@ -78,6 +78,9 @@
267 "vlan_vlan_create_notify",
268 "vlan_vlan_update_notify",
269 "vlan_vlan_delete_notify",
270+ "neighbour_neighbour_create_notify",
271+ "neighbour_neighbour_update_notify",
272+ "neighbour_neighbour_delete_notify",
273 "iprange_iprange_create_notify",
274 "iprange_iprange_update_notify",
275 "iprange_iprange_delete_notify",
276
277=== modified file 'src/maasserver/triggers/websocket.py'
278--- src/maasserver/triggers/websocket.py 2016-10-12 15:26:17 +0000
279+++ src/maasserver/triggers/websocket.py 2016-10-24 19:31:16 +0000
280@@ -1033,6 +1033,23 @@
281 register_trigger(
282 "maasserver_iprange", "iprange_delete_notify", "delete")
283
284+ # Neighbour table
285+ register_procedure(
286+ render_notification_procedure(
287+ 'neighbour_create_notify', 'neighbour_create', 'NEW.ip'))
288+ register_procedure(
289+ render_notification_procedure(
290+ 'neighbour_update_notify', 'neighbour_update', 'NEW.ip'))
291+ register_procedure(
292+ render_notification_procedure(
293+ 'neighbour_delete_notify', 'neighbour_delete', 'OLD.ip'))
294+ register_trigger(
295+ "maasserver_neighbour", "neighbour_create_notify", "insert")
296+ register_trigger(
297+ "maasserver_neighbour", "neighbour_update_notify", "update")
298+ register_trigger(
299+ "maasserver_neighbour", "neighbour_delete_notify", "delete")
300+
301 # StaticRoute table
302 register_procedure(
303 render_notification_procedure(
304
305=== modified file 'src/provisioningserver/utils/network.py'
306--- src/provisioningserver/utils/network.py 2016-10-21 18:59:57 +0000
307+++ src/provisioningserver/utils/network.py 2016-10-24 19:31:16 +0000
308@@ -11,12 +11,13 @@
309 'get_all_interface_addresses',
310 'is_loopback_address',
311 'make_network',
312+ 'reverseResolve',
313 'resolve_host_to_addrinfo',
314 'resolve_hostname',
315 'resolves_to_loopback_address',
316 'intersect_iprange',
317 'ip_range_within_network',
318- ]
319+]
320
321 import codecs
322 from collections import namedtuple
323@@ -56,6 +57,15 @@
324 from provisioningserver.utils.ps import running_in_container
325 from provisioningserver.utils.shell import call_and_check
326 from provisioningserver.utils.twisted import synchronous
327+from twisted.internet.defer import inlineCallbacks
328+from twisted.internet.interfaces import IResolver
329+from twisted.names.client import createResolver
330+from twisted.names.error import (
331+ AuthoritativeDomainError,
332+ DNSQueryTimeoutError,
333+ DomainError,
334+ ResolverError,
335+)
336
337 # Address families in /etc/network/interfaces that MAAS chooses to parse. All
338 # other families are ignored.
339@@ -73,6 +83,9 @@
340 ]
341
342
343+REVERSE_RESOLVE_RETRIES = (1, 2, 4, 8, 16)
344+
345+
346 # Type hints for `outer_range` parameter (get_unused_ranges()).
347 OuterRange = TypeVar('OuterRange', IPRange, IPNetwork, bytes, str)
348
349@@ -1225,3 +1238,89 @@
350 return any(
351 is_loopback_address(sockaddr[0])
352 for _, _, _, _, sockaddr in addrinfo)
353+
354+
355+def getDefaultIResolver() -> IResolver:
356+ """Get a new `IResolver` based on default resolver settings on this host.
357+
358+ That is, the Twisted default settings for the resolver will be used: first
359+ /etc/hosts will be checked, then the DNS servers /etc/resolv.conf will be
360+ queried.
361+
362+ If a resolver fails to be set up this way, a resolver will be created
363+ pointing to 127.0.0.1:53 (perhaps a dnsmasq or similar setup is in use),
364+ per the twisted behavior when using their globally-cached resolver.
365+ """
366+ # Rather than calling getResolver() here (which creates a global, cached
367+ # resolver), we create a new resolver each time. This is in case the
368+ # contents of /etc/hosts or /etc/resolv.conf have changed. (Doing this
369+ # should not be slow, but we do negate the benefits of the in-memory cache
370+ # in Twisted's global resolver.)
371+ try:
372+ return createResolver()
373+ except ValueError:
374+ # Mimic the behavior of the Twisted `getResolver()` function in case
375+ # the resolver cannot be created. (presumably, because there was an
376+ # error parsing /etc/resolv.conf or similar).
377+ return createResolver(servers=[('127.0.0.1', 53)])
378+
379+
380+def preferred_hostnames_sort_key(fqdn: str):
381+ """Return the sort key for the given FQDN, to sort in "preferred" order."""
382+ fqdn = fqdn.rstrip('.')
383+ subdomains = fqdn.split('.')
384+ # Sort by TLDs first.
385+ subdomains.reverse()
386+ key = (
387+ # First, prefer "more qualified" hostnames. (Since the sort will be
388+ # ascending, we need to negate this.) For example, if a reverse lookup
389+ # returns `[www.ubuntu.com, ubuntu.com]`, we prefer `www.ubuntu.com`,
390+ # even though 'w' sorts after 'u'.
391+ -len(subdomains),
392+ # Second, sort by domain components.
393+ subdomains
394+ )
395+ return key
396+
397+
398+@inlineCallbacks
399+def reverseResolve(
400+ ip: MaybeIPAddress, resolver: IResolver=None) -> Optional[List[str]]:
401+ """Using the specified IResolver, reverse-resolves the specifed `ip`.
402+
403+ :return: a sorted list of resolved hostnames (which the specified IP
404+ address reverse-resolves to). If the DNS lookup appeared to succeed,
405+ but no hostnames were found, returns an empty list. If the DNS lookup
406+ timed out or an error occurred, returns None.
407+ """
408+ if resolver is None:
409+ resolver = getDefaultIResolver()
410+ ip = IPAddress(ip)
411+ try:
412+ data = yield resolver.lookupPointer(
413+ ip.reverse_dns, timeout=REVERSE_RESOLVE_RETRIES)
414+ # I love the concise way in which I can ask the Twisted data structure
415+ # what the list of hostnames is. This is great.
416+ results = sorted(
417+ (rr.payload.name.name.decode("idna") for rr in data[0]),
418+ key=preferred_hostnames_sort_key
419+ )
420+ except AuthoritativeDomainError:
421+ # "Failed to reverse-resolve '%s': authoritative failure." % ip
422+ # This means the name didn't resolve, so return an empty list.
423+ return []
424+ except DomainError:
425+ # "Failed to reverse-resolve '%s': no records found." % ip
426+ # This means the name didn't resolve, so return an empty list.
427+ return []
428+ except DNSQueryTimeoutError:
429+ # "Failed to reverse-resolve '%s': timed out." % ip
430+ # Don't return an empty list since this implies a temporary failure.
431+ pass
432+ except ResolverError:
433+ # "Failed to reverse-resolve '%s': rejected by local resolver." % ip
434+ # Don't return an empty list since this could be temporary (unclear).
435+ pass
436+ else:
437+ return results
438+ return None
439
440=== modified file 'src/provisioningserver/utils/tests/test_network.py'
441--- src/provisioningserver/utils/tests/test_network.py 2016-10-21 18:28:17 +0000
442+++ src/provisioningserver/utils/tests/test_network.py 2016-10-24 19:31:16 +0000
443@@ -13,14 +13,22 @@
444 gaierror,
445 IPPROTO_TCP,
446 )
447+from typing import List
448 from unittest import mock
449-from unittest.mock import Mock
450+from unittest.mock import (
451+ call,
452+ Mock,
453+ sentinel,
454+)
455
456 from maastesting.factory import factory
457 from maastesting.matchers import (
458+ MockCalledOnce,
459 MockCalledOnceWith,
460+ MockCallsMatch,
461 MockNotCalled,
462 )
463+from maastesting.runtest import MAASTwistedRunTest
464 from maastesting.testcase import MAASTestCase
465 from netaddr import (
466 IPAddress,
467@@ -51,6 +59,7 @@
468 get_eui_organization,
469 get_interface_children,
470 get_mac_organization,
471+ getDefaultIResolver,
472 has_ipv4_address,
473 hex_str_to_bytes,
474 inet_ntop,
475@@ -64,11 +73,14 @@
476 make_iprange,
477 make_network,
478 parse_integer,
479+ preferred_hostnames_sort_key,
480 resolve_host_to_addrinfo,
481 resolve_hostname,
482 resolves_to_loopback_address,
483+ reverseResolve,
484 )
485 from provisioningserver.utils.shell import call_and_check
486+from testtools import ExpectedException
487 from testtools.matchers import (
488 Contains,
489 ContainsAll,
490@@ -79,6 +91,13 @@
491 MatchesSetwise,
492 Not,
493 )
494+from twisted.internet.defer import inlineCallbacks
495+from twisted.names.error import (
496+ AuthoritativeDomainError,
497+ DNSQueryTimeoutError,
498+ DomainError,
499+ ResolverError,
500+)
501
502
503 installed_curtin_version = call_and_check([
504@@ -1776,6 +1795,7 @@
505
506
507 class TestIsLoopbackAddress(MAASTestCase):
508+
509 def test_handles_ipv4_loopback(self):
510 network = IPNetwork('127.0.0.0/8')
511 address = factory.pick_ip_in_network(network)
512@@ -1825,6 +1845,7 @@
513
514
515 class TestResolvesToLoopbackAddress(MAASTestCase):
516+
517 def test_resolves_hostnames(self):
518 gai = self.patch(socket, 'getaddrinfo')
519 gai.return_value = ((
520@@ -1844,3 +1865,237 @@
521 self.assertEqual(resolves_to_loopback_address(name), False)
522 self.assertThat(
523 gai, MockCalledOnceWith(name, None, proto=IPPROTO_TCP))
524+
525+
526+class TestGetDefaultIResolver(MAASTestCase):
527+
528+ def setUp(self):
529+ super().setUp()
530+ self.createResolver = self.patch(
531+ network_module, "createResolver")
532+
533+ def test__uses_twisted_createResolver_by_default(self):
534+ self.createResolver.return_value = sentinel.default_resolver
535+ resolver = getDefaultIResolver()
536+ self.assertThat(self.createResolver, MockCalledOnceWith())
537+ self.assertThat(resolver, Is(sentinel.default_resolver))
538+
539+ def test__falls_back_to_localhost_based_resolver(self):
540+ self.createResolver.side_effect = [
541+ ValueError, sentinel.localhost_resolver]
542+ resolver = getDefaultIResolver()
543+ self.assertThat(resolver, Is(sentinel.localhost_resolver))
544+ self.assertThat(self.createResolver, MockCallsMatch(
545+ call(), call(servers=[('127.0.0.1', 53)])
546+ ))
547+
548+
549+class TestPreferredHostnamesSortKey(MAASTestCase):
550+ """Tests for `preferred_hostnames_sort_key()`."""
551+
552+ def test__sorts_flat_names(self):
553+ names = ('c', 'b', 'a', 'z')
554+ self.assertThat(
555+ sorted(names, key=preferred_hostnames_sort_key),
556+ Equals(['a', 'b', 'c', 'z'])
557+ )
558+
559+ def test__sorts_more_qualified_names_first(self):
560+ names = (
561+ 'diediedie.archive.ubuntu.com',
562+ 'wpps.org.za',
563+ 'ubuntu.com',
564+ 'ubuntu.sets.a.great.example.com',
565+ )
566+ self.assertThat(
567+ sorted(names, key=preferred_hostnames_sort_key),
568+ Equals([
569+ 'ubuntu.sets.a.great.example.com',
570+ 'diediedie.archive.ubuntu.com',
571+ 'wpps.org.za',
572+ 'ubuntu.com',
573+ ])
574+ )
575+
576+ def test__sorts_by_domains_then_hostnames_within_each_domain(self):
577+ names = (
578+ 'www.ubuntu.com',
579+ 'maas.ubuntu.com',
580+ 'us.archive.ubuntu.com',
581+ 'uk.archive.ubuntu.com',
582+ 'foo.maas.ubuntu.com',
583+ 'archive.ubuntu.com',
584+ 'maas-xenial',
585+ 'juju-xenial',
586+ )
587+ self.assertThat(
588+ sorted(names, key=preferred_hostnames_sort_key),
589+ Equals([
590+ 'uk.archive.ubuntu.com',
591+ 'us.archive.ubuntu.com',
592+ 'foo.maas.ubuntu.com',
593+ 'archive.ubuntu.com',
594+ 'maas.ubuntu.com',
595+ 'www.ubuntu.com',
596+ 'juju-xenial',
597+ 'maas-xenial',
598+ ])
599+ )
600+
601+ def test__sorts_by_tlds_first(self):
602+ names = (
603+ 'com.za',
604+ 'com.com',
605+ 'com.org',
606+ 'www.ubuntu.org',
607+ 'www.ubuntu.com',
608+ 'www.ubuntu.za',
609+ )
610+ self.assertThat(
611+ sorted(names, key=preferred_hostnames_sort_key),
612+ Equals([
613+ 'www.ubuntu.com',
614+ 'www.ubuntu.org',
615+ 'www.ubuntu.za',
616+ 'com.com',
617+ 'com.org',
618+ 'com.za',
619+ ])
620+ )
621+
622+ def test__ignores_trailing_periods(self):
623+ names = (
624+ 'com.za.',
625+ 'com.com',
626+ 'com.org.',
627+ 'www.ubuntu.org',
628+ 'www.ubuntu.com.',
629+ 'www.ubuntu.za',
630+ )
631+ self.assertThat(
632+ sorted(names, key=preferred_hostnames_sort_key),
633+ Equals([
634+ 'www.ubuntu.com.',
635+ 'www.ubuntu.org',
636+ 'www.ubuntu.za',
637+ 'com.com',
638+ 'com.org.',
639+ 'com.za.',
640+ ])
641+ )
642+
643+
644+class TestReverseResolveMixIn:
645+ """Test fixture mix-in for unit tests calling `reverseResolve()`."""
646+
647+ def set_fake_twisted_dns_reply(self, hostnames: List[str]):
648+ """Sets up the rrset data structure for the mock resolver.
649+
650+ This method must be called for any test cases in this suite which
651+ intend to test the data being returned.
652+ """
653+ rrset = []
654+ data = (rrset,)
655+ for hostname in hostnames:
656+ rr = Mock()
657+ rr.payload = Mock()
658+ rr.payload.name = Mock()
659+ rr.payload.name.name = Mock()
660+ rr.payload.name.name.decode = Mock()
661+ rr.payload.name.name.decode.return_value = hostname
662+ rrset.append(rr)
663+ self.reply = data
664+
665+ @inlineCallbacks
666+ def mock_lookupPointer(self, ip, timeout=None):
667+ self.timeout = timeout
668+ yield
669+ return self.reply
670+
671+ def get_mock_iresolver(self):
672+ resolver = Mock()
673+ resolver.lookupPointer = Mock()
674+ resolver.lookupPointer.side_effect = self.mock_lookupPointer
675+ return resolver
676+
677+ def setUp(self):
678+ super().setUp()
679+ self.timeout = object()
680+ self.getDefaultIResolver = self.patch(
681+ network_module.getDefaultIResolver)
682+ self.resolver = self.get_mock_iresolver()
683+ self.lookupPointer = self.resolver.lookupPointer
684+ self.getDefaultIResolver.return_value = self.resolver
685+
686+
687+class TestReverseResolve(TestReverseResolveMixIn, MAASTestCase):
688+ """Tests for `reverseResolve`."""
689+
690+ run_tests_with = MAASTwistedRunTest.make_factory(timeout=30)
691+
692+ @inlineCallbacks
693+ def test__uses_resolver_from_getDefauldIResolver_by_default(self):
694+ self.set_fake_twisted_dns_reply([])
695+ yield reverseResolve(factory.make_ip_address())
696+ self.assertThat(self.getDefaultIResolver, MockCalledOnceWith())
697+
698+ @inlineCallbacks
699+ def test__uses_passed_in_IResolver_if_specified(self):
700+ self.set_fake_twisted_dns_reply([])
701+ some_other_resolver = self.get_mock_iresolver()
702+ yield reverseResolve(
703+ factory.make_ip_address(), resolver=some_other_resolver)
704+ self.assertThat(self.getDefaultIResolver, MockNotCalled())
705+ self.assertThat(some_other_resolver.lookupPointer, MockCalledOnce())
706+
707+ @inlineCallbacks
708+ def test__returns_single_domain(self):
709+ reverse_dns_reply = ['example.com']
710+ self.set_fake_twisted_dns_reply(reverse_dns_reply)
711+ result = yield reverseResolve(factory.make_ip_address())
712+ self.expectThat(result, Equals(reverse_dns_reply))
713+
714+ @inlineCallbacks
715+ def test__returns_multiple_sorted_domains(self):
716+ reverse_dns_reply = ['ubuntu.com', 'example.com']
717+ self.set_fake_twisted_dns_reply(reverse_dns_reply)
718+ result = yield reverseResolve(factory.make_ip_address())
719+ self.expectThat(result, Equals(
720+ sorted(reverse_dns_reply, key=preferred_hostnames_sort_key)))
721+
722+ @inlineCallbacks
723+ def test__empty_list_for_empty_rrset(self):
724+ reverse_dns_reply = []
725+ self.set_fake_twisted_dns_reply(reverse_dns_reply)
726+ result = yield reverseResolve(factory.make_ip_address())
727+ self.expectThat(result, Equals(reverse_dns_reply))
728+
729+ @inlineCallbacks
730+ def test__returns_empty_list_for_domainerror(self):
731+ self.lookupPointer.side_effect = DomainError
732+ result = yield reverseResolve(factory.make_ip_address())
733+ self.expectThat(result, Equals([]))
734+
735+ @inlineCallbacks
736+ def test__returns_empty_list_for_authoritativedomainerror(self):
737+ self.lookupPointer.side_effect = AuthoritativeDomainError
738+ result = yield reverseResolve(factory.make_ip_address())
739+ self.expectThat(result, Equals([]))
740+
741+ @inlineCallbacks
742+ def test__returns_none_for_dnsquerytimeouterror(self):
743+ self.lookupPointer.side_effect = DNSQueryTimeoutError(None)
744+ result = yield reverseResolve(factory.make_ip_address())
745+ self.expectThat(result, Is(None))
746+
747+ @inlineCallbacks
748+ def test__returns_none_for_resolvererror(self):
749+ self.lookupPointer.side_effect = ResolverError
750+ result = yield reverseResolve(factory.make_ip_address())
751+ self.expectThat(result, Is(None))
752+
753+ @inlineCallbacks
754+ def test__raises_for_unhandled_error(self):
755+ self.lookupPointer.side_effect = TypeError
756+ with ExpectedException(TypeError):
757+ yield reverseResolve(factory.make_ip_address())