Merge lp:~mpontillo/maas/rdns-service--bug-1633822--part2 into lp:~maas-committers/maas/trunk
- rdns-service--bug-1633822--part2
- Merge into 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 | ||||
Related bugs: |
|
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.
Description of the change
To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote : | # |
Attempt to merge into lp:maas failed due to conflicts:
text conflict in src/provisionin
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()) |
LGTM!