Merge lp:~jtv/maas/bug-1372767 into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 3059
Proposed branch: lp:~jtv/maas/bug-1372767
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 192 lines (+121/-1)
2 files modified
src/provisioningserver/rpc/clusterservice.py (+54/-0)
src/provisioningserver/rpc/tests/test_clusterservice.py (+67/-1)
To merge this branch: bzr merge lp:~jtv/maas/bug-1372767
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+235616@code.launchpad.net

Commit message

Kludge around Twisted bug (our bug 1372767): its web client breaks when the cluster controller tries to access the region controller API on a URL based on an IPv6 address.

Description of the change

This monkey-patches into place a derived-and-improved version of class twisted.web.client._URI. The replacement can deal with IPv6 addresses in URLs. It could probably be smarter but given that I'm already resorting to a fairly ugly-looking regex, I figured I'd try to keep it dumb so bugs won't come down to something as hard to notice as a parenthesis being in the wrong place.

Jeroen

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

Tip top.

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Thanks for the review. I was sort of expecting to be chastised for the kludge.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/provisioningserver/rpc/clusterservice.py'
2--- src/provisioningserver/rpc/clusterservice.py 2014-09-23 09:24:19 +0000
3+++ src/provisioningserver/rpc/clusterservice.py 2014-09-23 11:50:34 +0000
4@@ -19,6 +19,7 @@
5 import json
6 import logging
7 import random
8+import re
9 from urlparse import urlparse
10
11 from apiclient.creds import convert_string_to_tuple
12@@ -81,6 +82,8 @@
13 from twisted.internet.threads import deferToThread
14 from twisted.protocols import amp
15 from twisted.python import log
16+from twisted.web import http
17+import twisted.web.client
18 from twisted.web.client import getPage
19 from zope.interface import implementer
20
21@@ -380,6 +383,49 @@
22 log.msg("Peer certificate: %r" % self.peerCertificate)
23
24
25+class PatchedURI(twisted.web.client._URI):
26+
27+ @classmethod
28+ def fromBytes(cls, uri, defaultPort=None):
29+ """Patched replacement for `twisted.web.client._URI.fromBytes`.
30+
31+ The Twisted version of this function breaks when you give it a URL
32+ whose netloc is based on an IPv6 address.
33+ """
34+ uri = uri.strip()
35+ scheme, netloc, path, params, query, fragment = http.urlparse(uri)
36+
37+ if defaultPort is None:
38+ scheme_ports = {
39+ 'https': 443,
40+ 'http': 80,
41+ }
42+ defaultPort = scheme_ports.get(scheme, 80)
43+
44+ if '[' in netloc:
45+ # IPv6 address. This is complicated.
46+ parsed_netloc = re.match(
47+ '(?P<host>\\[[0-9A-Fa-f:.]+\\])([:](?P<port>[0-9]+))?$',
48+ netloc)
49+ host, port = parsed_netloc.group('host', 'port')
50+ elif ':' in netloc:
51+ # IPv4 address or hostname, with port spec. This is easy.
52+ host, port = netloc.split(':')
53+ else:
54+ # IPv4 address or hostname, without port spec. This is trivial.
55+ host = netloc
56+ port = None
57+
58+ if port is None:
59+ port = defaultPort
60+ try:
61+ port = int(port)
62+ except ValueError:
63+ port = defaultPort
64+
65+ return cls(scheme, netloc, host, port, path, params, query, fragment)
66+
67+
68 class ClusterClientService(TimerService, object):
69 """A cluster controller RPC client service.
70
71@@ -402,6 +448,14 @@
72 self.connections = {}
73 self.clock = reactor
74
75+ # XXX jtv 2014-09-23, bug=1372767: Fix
76+ # twisted.web.client._URI.fromBytes to handle IPv6 addresses.
77+ # A `getPage` call on Twisted's web client breaks if you give it a
78+ # URL with an IPv6 address, at the point where `_makeGetterFactory`
79+ # calls `fromBytes`. That last function assumes that a colon can only
80+ # occur in the URL's netloc portion as part of a port specification.
81+ twisted.web.client._URI = PatchedURI
82+
83 def getClient(self):
84 """Returns a :class:`common.Client` connected to a region.
85
86
87=== modified file 'src/provisioningserver/rpc/tests/test_clusterservice.py'
88--- src/provisioningserver/rpc/tests/test_clusterservice.py 2014-09-23 09:24:19 +0000
89+++ src/provisioningserver/rpc/tests/test_clusterservice.py 2014-09-23 11:50:34 +0000
90@@ -39,6 +39,7 @@
91 MAASTwistedRunTest,
92 )
93 from mock import (
94+ ANY,
95 call,
96 Mock,
97 sentinel,
98@@ -71,6 +72,7 @@
99 Cluster,
100 ClusterClient,
101 ClusterClientService,
102+ PatchedURI,
103 )
104 from provisioningserver.rpc.interfaces import IConnection
105 from provisioningserver.rpc.monitors import (
106@@ -105,7 +107,10 @@
107 Not,
108 )
109 from twisted.application.internet import TimerService
110-from twisted.internet import error
111+from twisted.internet import (
112+ error,
113+ reactor,
114+ )
115 from twisted.internet.defer import (
116 inlineCallbacks,
117 succeed,
118@@ -305,6 +310,60 @@
119 JSON_POWER_TYPE_PARAMETERS, response["power_types"])
120
121
122+class TestPatchedURI(MAASTestCase):
123+
124+ def test__parses_URL_with_hostname(self):
125+ hostname = factory.make_name('host').encode('ascii')
126+ path = factory.make_name('path').encode('ascii')
127+ uri = PatchedURI.fromBytes(b'http://%s/%s' % (hostname, path))
128+ self.expectThat(uri.host, Equals(hostname))
129+ self.expectThat(uri.path, Equals('/%s' % path))
130+ self.expectThat(uri.port, Equals(80))
131+
132+ def test__parses_URL_with_hostname_and_port(self):
133+ hostname = factory.make_name('host').encode('ascii')
134+ port = factory.pick_port()
135+ path = factory.make_name('path').encode('ascii')
136+ uri = PatchedURI.fromBytes(b'http://%s:%d/%s' % (hostname, port, path))
137+ self.expectThat(uri.host, Equals(hostname))
138+ self.expectThat(uri.path, Equals('/%s' % path))
139+ self.expectThat(uri.port, Equals(port))
140+
141+ def test__parses_URL_with_IPv4_address(self):
142+ ip = factory.make_ipv4_address().encode('ascii')
143+ path = factory.make_name('path').encode('ascii')
144+ uri = PatchedURI.fromBytes(b'http://%s/%s' % (ip, path))
145+ self.expectThat(uri.host, Equals(ip.encode('ascii')))
146+ self.expectThat(uri.path, Equals('/%s' % path))
147+ self.expectThat(uri.port, Equals(80))
148+
149+ def test__parses_URL_with_IPv4_address_and_port(self):
150+ ip = factory.make_ipv4_address().encode('ascii')
151+ port = factory.pick_port()
152+ path = factory.make_name('path').encode('ascii')
153+ uri = PatchedURI.fromBytes(b'http://%s:%d/%s' % (ip, port, path))
154+ self.expectThat(uri.host, Equals(ip))
155+ self.expectThat(uri.path, Equals('/%s' % path))
156+ self.expectThat(uri.port, Equals(port))
157+
158+ def test__parses_URL_with_IPv6_address(self):
159+ ip = factory.make_ipv6_address().encode('ascii')
160+ path = factory.make_name('path').encode('ascii')
161+ uri = PatchedURI.fromBytes(b'http://[%s]/%s' % (ip, path))
162+ self.expectThat(uri.host, Equals(b'[%s]' % ip))
163+ self.expectThat(uri.path, Equals(b'/%s' % path))
164+ self.expectThat(uri.port, Equals(80))
165+
166+ def test__parses_URL_with_IPv6_address_and_port(self):
167+ ip = factory.make_ipv6_address().encode('ascii')
168+ port = factory.pick_port()
169+ path = factory.make_name('path').encode('ascii')
170+ uri = PatchedURI.fromBytes(b'http://[%s]:%d/%s' % (ip, port, path))
171+ self.expectThat(uri.host, Equals(b'[%s]' % ip))
172+ self.expectThat(uri.path, Equals(b'/%s' % path))
173+ self.expectThat(uri.port, Equals(port))
174+
175+
176 class TestClusterClientService(MAASTestCase):
177
178 run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)
179@@ -340,6 +399,13 @@
180 "Region not available: Connection was refused by other side.",
181 logger.dump())
182
183+ def test__get_rpc_info_accepts_IPv6_url(self):
184+ connect_tcp = self.patch_autospec(reactor, 'connectTCP')
185+ url = factory.make_url(
186+ scheme='http', netloc='[%s]' % factory.make_ipv6_address())
187+ ClusterClientService(Clock())._fetch_rpc_info(url.encode('ascii'))
188+ self.assertThat(connect_tcp, MockCalledOnceWith(ANY, ANY, ANY))
189+
190 # The following represents an example response from the RPC info
191 # view in maasserver. Event-loops listen on ephemeral ports, and
192 # it's up to the RPC info view to direct clients to them.