Merge lp:~allenap/maas/pass-addresses-to-tftp-backend into lp:~maas-committers/maas/trunk
- pass-addresses-to-tftp-backend
- Merge into trunk
Proposed by
Gavin Panella
Status: | Merged |
---|---|
Approved by: | Gavin Panella |
Approved revision: | no longer in the source branch. |
Merged at revision: | 1315 |
Proposed branch: | lp:~allenap/maas/pass-addresses-to-tftp-backend |
Merge into: | lp:~maas-committers/maas/trunk |
Diff against target: |
313 lines (+142/-14) 6 files modified
contrib/python-tx-tftp/tftp/protocol.py (+14/-2) contrib/python-tx-tftp/tftp/test/test_protocol.py (+73/-2) src/maasserver/api.py (+4/-1) src/maasserver/tests/test_api.py (+25/-7) src/provisioningserver/tests/test_tftp.py (+20/-2) src/provisioningserver/tftp.py (+6/-0) |
To merge this branch: | bzr merge lp:~allenap/maas/pass-addresses-to-tftp-backend |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Julian Edwards (community) | Approve | ||
Review via email: mp+132015@code.launchpad.net |
Commit message
Use the node-facing address of the cluster controller as the iSCSI host.
The cluster and node addresses are passed over to the pxeconfig view.
Description of the change
To post a comment you must log in.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'contrib/python-tx-tftp/tftp/protocol.py' | |||
2 | --- contrib/python-tx-tftp/tftp/protocol.py 2012-07-05 14:36:27 +0000 | |||
3 | +++ contrib/python-tx-tftp/tftp/protocol.py 2012-10-30 09:11:22 +0000 | |||
4 | @@ -12,6 +12,7 @@ | |||
5 | 12 | from twisted.internet.defer import inlineCallbacks, returnValue | 12 | from twisted.internet.defer import inlineCallbacks, returnValue |
6 | 13 | from twisted.internet.protocol import DatagramProtocol | 13 | from twisted.internet.protocol import DatagramProtocol |
7 | 14 | from twisted.python import log | 14 | from twisted.python import log |
8 | 15 | from twisted.python.context import call | ||
9 | 15 | 16 | ||
10 | 16 | 17 | ||
11 | 17 | class TFTP(DatagramProtocol): | 18 | class TFTP(DatagramProtocol): |
12 | @@ -48,11 +49,22 @@ | |||
13 | 48 | 49 | ||
14 | 49 | @inlineCallbacks | 50 | @inlineCallbacks |
15 | 50 | def _startSession(self, datagram, addr, mode): | 51 | def _startSession(self, datagram, addr, mode): |
16 | 52 | # Set up a call context so that we can pass extra arbitrary | ||
17 | 53 | # information to interested backends without adding extra call | ||
18 | 54 | # arguments, or switching to using a request object, for example. | ||
19 | 55 | context = {} | ||
20 | 56 | if self.transport is not None: | ||
21 | 57 | # Add the local and remote addresses to the call context. | ||
22 | 58 | local = self.transport.getHost() | ||
23 | 59 | context["local"] = local.host, local.port | ||
24 | 60 | context["remote"] = addr | ||
25 | 51 | try: | 61 | try: |
26 | 52 | if datagram.opcode == OP_WRQ: | 62 | if datagram.opcode == OP_WRQ: |
28 | 53 | fs_interface = yield self.backend.get_writer(datagram.filename) | 63 | fs_interface = yield call( |
29 | 64 | context, self.backend.get_writer, datagram.filename) | ||
30 | 54 | elif datagram.opcode == OP_RRQ: | 65 | elif datagram.opcode == OP_RRQ: |
32 | 55 | fs_interface = yield self.backend.get_reader(datagram.filename) | 66 | fs_interface = yield call( |
33 | 67 | context, self.backend.get_reader, datagram.filename) | ||
34 | 56 | except Unsupported, e: | 68 | except Unsupported, e: |
35 | 57 | self.transport.write(ERRORDatagram.from_code(ERR_ILLEGAL_OP, | 69 | self.transport.write(ERRORDatagram.from_code(ERR_ILLEGAL_OP, |
36 | 58 | str(e)).to_wire(), addr) | 70 | str(e)).to_wire(), addr) |
37 | 59 | 71 | ||
38 | === modified file 'contrib/python-tx-tftp/tftp/test/test_protocol.py' | |||
39 | --- contrib/python-tx-tftp/tftp/test/test_protocol.py 2012-07-25 19:59:37 +0000 | |||
40 | +++ contrib/python-tx-tftp/tftp/test/test_protocol.py 2012-10-30 09:11:22 +0000 | |||
41 | @@ -11,9 +11,11 @@ | |||
42 | 11 | from tftp.netascii import NetasciiReceiverProxy, NetasciiSenderProxy | 11 | from tftp.netascii import NetasciiReceiverProxy, NetasciiSenderProxy |
43 | 12 | from tftp.protocol import TFTP | 12 | from tftp.protocol import TFTP |
44 | 13 | from twisted.internet import reactor | 13 | from twisted.internet import reactor |
46 | 14 | from twisted.internet.defer import Deferred | 14 | from twisted.internet.address import IPv4Address |
47 | 15 | from twisted.internet.defer import Deferred, inlineCallbacks | ||
48 | 15 | from twisted.internet.protocol import DatagramProtocol | 16 | from twisted.internet.protocol import DatagramProtocol |
49 | 16 | from twisted.internet.task import Clock | 17 | from twisted.internet.task import Clock |
50 | 18 | from twisted.python import context | ||
51 | 17 | from twisted.python.filepath import FilePath | 19 | from twisted.python.filepath import FilePath |
52 | 18 | from twisted.test.proto_helpers import StringTransport | 20 | from twisted.test.proto_helpers import StringTransport |
53 | 19 | from twisted.trial import unittest | 21 | from twisted.trial import unittest |
54 | @@ -50,7 +52,8 @@ | |||
55 | 50 | 52 | ||
56 | 51 | def setUp(self): | 53 | def setUp(self): |
57 | 52 | self.clock = Clock() | 54 | self.clock = Clock() |
59 | 53 | self.transport = FakeTransport(hostAddress=('127.0.0.1', self.port)) | 55 | self.transport = FakeTransport( |
60 | 56 | hostAddress=IPv4Address('UDP', '127.0.0.1', self.port)) | ||
61 | 54 | 57 | ||
62 | 55 | def test_malformed_datagram(self): | 58 | def test_malformed_datagram(self): |
63 | 56 | tftp = TFTP(BackendFactory(), _clock=self.clock) | 59 | tftp = TFTP(BackendFactory(), _clock=self.clock) |
64 | @@ -247,3 +250,71 @@ | |||
65 | 247 | self.clock.advance(1) | 250 | self.clock.advance(1) |
66 | 248 | self.assertTrue(d.called) | 251 | self.assertTrue(d.called) |
67 | 249 | self.assertTrue(IWriter.providedBy(d.result.backend)) | 252 | self.assertTrue(IWriter.providedBy(d.result.backend)) |
68 | 253 | |||
69 | 254 | |||
70 | 255 | class CapturedContext(Exception): | ||
71 | 256 | """A donkey, to carry the call context back up the stack.""" | ||
72 | 257 | |||
73 | 258 | def __init__(self, args, names): | ||
74 | 259 | super(CapturedContext, self).__init__(*args) | ||
75 | 260 | self.context = {name: context.get(name) for name in names} | ||
76 | 261 | |||
77 | 262 | |||
78 | 263 | class ContextCapturingBackend(object): | ||
79 | 264 | """A fake `IBackend` that raises `CapturedContext`. | ||
80 | 265 | |||
81 | 266 | Calling `get_reader` or `get_writer` raises a `CapturedContext` exception, | ||
82 | 267 | which captures the values of the call context for the given `names`. | ||
83 | 268 | """ | ||
84 | 269 | |||
85 | 270 | def __init__(self, *names): | ||
86 | 271 | self.names = names | ||
87 | 272 | |||
88 | 273 | def get_reader(self, file_name): | ||
89 | 274 | raise CapturedContext(("get_reader", file_name), self.names) | ||
90 | 275 | |||
91 | 276 | def get_writer(self, file_name): | ||
92 | 277 | raise CapturedContext(("get_writer", file_name), self.names) | ||
93 | 278 | |||
94 | 279 | |||
95 | 280 | class HostTransport(object): | ||
96 | 281 | """A fake `ITransport` that only responds to `getHost`.""" | ||
97 | 282 | |||
98 | 283 | def __init__(self, host): | ||
99 | 284 | self.host = host | ||
100 | 285 | |||
101 | 286 | def getHost(self): | ||
102 | 287 | return IPv4Address("UDP", *self.host) | ||
103 | 288 | |||
104 | 289 | |||
105 | 290 | class BackendCallingContext(unittest.TestCase): | ||
106 | 291 | |||
107 | 292 | def setUp(self): | ||
108 | 293 | super(BackendCallingContext, self).setUp() | ||
109 | 294 | self.backend = ContextCapturingBackend("local", "remote") | ||
110 | 295 | self.tftp = TFTP(self.backend) | ||
111 | 296 | self.tftp.transport = HostTransport(("12.34.56.78", 1234)) | ||
112 | 297 | |||
113 | 298 | @inlineCallbacks | ||
114 | 299 | def test_context_rrq(self): | ||
115 | 300 | rrq_datagram = RRQDatagram('nonempty', 'NetASCiI', {}) | ||
116 | 301 | rrq_addr = ('127.0.0.1', 1069) | ||
117 | 302 | error = yield self.assertFailure( | ||
118 | 303 | self.tftp._startSession(rrq_datagram, rrq_addr, "octet"), | ||
119 | 304 | CapturedContext) | ||
120 | 305 | self.assertEqual(("get_reader", rrq_datagram.filename), error.args) | ||
121 | 306 | self.assertEqual( | ||
122 | 307 | {"local": self.tftp.transport.host, "remote": rrq_addr}, | ||
123 | 308 | error.context) | ||
124 | 309 | |||
125 | 310 | @inlineCallbacks | ||
126 | 311 | def test_context_wrq(self): | ||
127 | 312 | wrq_datagram = WRQDatagram('nonempty', 'NetASCiI', {}) | ||
128 | 313 | wrq_addr = ('127.0.0.1', 1069) | ||
129 | 314 | error = yield self.assertFailure( | ||
130 | 315 | self.tftp._startSession(wrq_datagram, wrq_addr, "octet"), | ||
131 | 316 | CapturedContext) | ||
132 | 317 | self.assertEqual(("get_writer", wrq_datagram.filename), error.args) | ||
133 | 318 | self.assertEqual( | ||
134 | 319 | {"local": self.tftp.transport.host, "remote": wrq_addr}, | ||
135 | 320 | error.context) | ||
136 | 250 | 321 | ||
137 | === modified file 'src/maasserver/api.py' | |||
138 | --- src/maasserver/api.py 2012-10-26 10:56:06 +0000 | |||
139 | +++ src/maasserver/api.py 2012-10-30 09:11:22 +0000 | |||
140 | @@ -1792,6 +1792,8 @@ | |||
141 | 1792 | :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not | 1792 | :param arch: Architecture name (in the pxelinux namespace, eg. 'arm' not |
142 | 1793 | 'armhf'). | 1793 | 'armhf'). |
143 | 1794 | :param subarch: Subarchitecture name (in the pxelinux namespace). | 1794 | :param subarch: Subarchitecture name (in the pxelinux namespace). |
144 | 1795 | :param local: The IP address of the cluster controller. | ||
145 | 1796 | :param remote: The IP address of the booting node. | ||
146 | 1795 | """ | 1797 | """ |
147 | 1796 | node = get_node_from_mac_string(request.GET.get('mac', None)) | 1798 | node = get_node_from_mac_string(request.GET.get('mac', None)) |
148 | 1797 | 1799 | ||
149 | @@ -1846,11 +1848,12 @@ | |||
150 | 1846 | 1848 | ||
151 | 1847 | purpose = get_boot_purpose(node) | 1849 | purpose = get_boot_purpose(node) |
152 | 1848 | server_address = get_maas_facing_server_address() | 1850 | server_address = get_maas_facing_server_address() |
153 | 1851 | cluster_address = get_mandatory_param(request.GET, "local") | ||
154 | 1849 | 1852 | ||
155 | 1850 | params = KernelParameters( | 1853 | params = KernelParameters( |
156 | 1851 | arch=arch, subarch=subarch, release=series, purpose=purpose, | 1854 | arch=arch, subarch=subarch, release=series, purpose=purpose, |
157 | 1852 | hostname=hostname, domain=domain, preseed_url=preseed_url, | 1855 | hostname=hostname, domain=domain, preseed_url=preseed_url, |
159 | 1853 | log_host=server_address, fs_host=server_address) | 1856 | log_host=server_address, fs_host=cluster_address) |
160 | 1854 | 1857 | ||
161 | 1855 | return HttpResponse( | 1858 | return HttpResponse( |
162 | 1856 | json.dumps(params._asdict()), | 1859 | json.dumps(params._asdict()), |
163 | 1857 | 1860 | ||
164 | === modified file 'src/maasserver/tests/test_api.py' | |||
165 | --- src/maasserver/tests/test_api.py 2012-10-26 10:56:06 +0000 | |||
166 | +++ src/maasserver/tests/test_api.py 2012-10-30 09:11:22 +0000 | |||
167 | @@ -3202,11 +3202,16 @@ | |||
168 | 3202 | 3202 | ||
169 | 3203 | class TestPXEConfigAPI(AnonAPITestCase): | 3203 | class TestPXEConfigAPI(AnonAPITestCase): |
170 | 3204 | 3204 | ||
171 | 3205 | def get_default_params(self): | ||
172 | 3206 | return { | ||
173 | 3207 | "local": factory.getRandomIPAddress(), | ||
174 | 3208 | "remote": factory.getRandomIPAddress(), | ||
175 | 3209 | } | ||
176 | 3210 | |||
177 | 3205 | def get_mac_params(self): | 3211 | def get_mac_params(self): |
182 | 3206 | return {'mac': factory.make_mac_address().mac_address} | 3212 | params = self.get_default_params() |
183 | 3207 | 3213 | params['mac'] = factory.make_mac_address().mac_address | |
184 | 3208 | def get_default_params(self): | 3214 | return params |
181 | 3209 | return dict() | ||
185 | 3210 | 3215 | ||
186 | 3211 | def get_pxeconfig(self, params=None): | 3216 | def get_pxeconfig(self, params=None): |
187 | 3212 | """Make a request to `pxeconfig`, and return its response dict.""" | 3217 | """Make a request to `pxeconfig`, and return its response dict.""" |
188 | @@ -3240,7 +3245,8 @@ | |||
189 | 3240 | ContainsAll(KernelParameters._fields)) | 3245 | ContainsAll(KernelParameters._fields)) |
190 | 3241 | 3246 | ||
191 | 3242 | def test_pxeconfig_returns_data_for_known_node(self): | 3247 | def test_pxeconfig_returns_data_for_known_node(self): |
193 | 3243 | response = self.client.get(reverse('pxeconfig'), self.get_mac_params()) | 3248 | params = self.get_mac_params() |
194 | 3249 | response = self.client.get(reverse('pxeconfig'), params) | ||
195 | 3244 | self.assertEqual(httplib.OK, response.status_code) | 3250 | self.assertEqual(httplib.OK, response.status_code) |
196 | 3245 | 3251 | ||
197 | 3246 | def test_pxeconfig_returns_no_content_for_unknown_node(self): | 3252 | def test_pxeconfig_returns_no_content_for_unknown_node(self): |
198 | @@ -3252,6 +3258,7 @@ | |||
199 | 3252 | architecture = factory.getRandomEnum(ARCHITECTURE) | 3258 | architecture = factory.getRandomEnum(ARCHITECTURE) |
200 | 3253 | arch, subarch = architecture.split('/') | 3259 | arch, subarch = architecture.split('/') |
201 | 3254 | params = dict( | 3260 | params = dict( |
202 | 3261 | self.get_default_params(), | ||
203 | 3255 | mac=factory.getRandomMACAddress(delimiter=b'-'), | 3262 | mac=factory.getRandomMACAddress(delimiter=b'-'), |
204 | 3256 | arch=arch, | 3263 | arch=arch, |
205 | 3257 | subarch=subarch) | 3264 | subarch=subarch) |
206 | @@ -3280,15 +3287,19 @@ | |||
207 | 3280 | full_hostname = '.'.join([host, domain]) | 3287 | full_hostname = '.'.join([host, domain]) |
208 | 3281 | node = factory.make_node(hostname=full_hostname) | 3288 | node = factory.make_node(hostname=full_hostname) |
209 | 3282 | mac = factory.make_mac_address(node=node) | 3289 | mac = factory.make_mac_address(node=node) |
211 | 3283 | pxe_config = self.get_pxeconfig(params={'mac': mac.mac_address}) | 3290 | params = self.get_default_params() |
212 | 3291 | params['mac'] = mac.mac_address | ||
213 | 3292 | pxe_config = self.get_pxeconfig(params) | ||
214 | 3284 | self.assertEqual(host, pxe_config.get('hostname')) | 3293 | self.assertEqual(host, pxe_config.get('hostname')) |
215 | 3285 | self.assertNotIn(domain, pxe_config.values()) | 3294 | self.assertNotIn(domain, pxe_config.values()) |
216 | 3286 | 3295 | ||
217 | 3287 | def test_pxeconfig_uses_nodegroup_domain_for_node(self): | 3296 | def test_pxeconfig_uses_nodegroup_domain_for_node(self): |
218 | 3288 | mac = factory.make_mac_address() | 3297 | mac = factory.make_mac_address() |
219 | 3298 | params = self.get_default_params() | ||
220 | 3299 | params['mac'] = mac | ||
221 | 3289 | self.assertEqual( | 3300 | self.assertEqual( |
222 | 3290 | mac.node.nodegroup.name, | 3301 | mac.node.nodegroup.name, |
224 | 3291 | self.get_pxeconfig({'mac': mac.mac_address}).get('domain')) | 3302 | self.get_pxeconfig(params).get('domain')) |
225 | 3292 | 3303 | ||
226 | 3293 | def get_without_param(self, param): | 3304 | def get_without_param(self, param): |
227 | 3294 | """Request a `pxeconfig()` response, but omit `param` from request.""" | 3305 | """Request a `pxeconfig()` response, but omit `param` from request.""" |
228 | @@ -3353,6 +3364,13 @@ | |||
229 | 3353 | fake_boot_purpose, | 3364 | fake_boot_purpose, |
230 | 3354 | json.loads(response.content)["purpose"]) | 3365 | json.loads(response.content)["purpose"]) |
231 | 3355 | 3366 | ||
232 | 3367 | def test_pxeconfig_returns_fs_host_as_cluster_controller(self): | ||
233 | 3368 | # The kernel parameter `fs_host` points to the cluster controller | ||
234 | 3369 | # address, which is passed over within the `local` parameter. | ||
235 | 3370 | params = self.get_default_params() | ||
236 | 3371 | kernel_params = KernelParameters(**self.get_pxeconfig(params)) | ||
237 | 3372 | self.assertEqual(params["local"], kernel_params.fs_host) | ||
238 | 3373 | |||
239 | 3356 | 3374 | ||
240 | 3357 | class TestNodeGroupsAPI(APIv10TestMixin, MultipleUsersScenarios, TestCase): | 3375 | class TestNodeGroupsAPI(APIv10TestMixin, MultipleUsersScenarios, TestCase): |
241 | 3358 | scenarios = [ | 3376 | scenarios = [ |
242 | 3359 | 3377 | ||
243 | === modified file 'src/provisioningserver/tests/test_tftp.py' | |||
244 | --- src/provisioningserver/tests/test_tftp.py 2012-10-02 10:53:22 +0000 | |||
245 | +++ src/provisioningserver/tests/test_tftp.py 2012-10-30 09:11:22 +0000 | |||
246 | @@ -35,6 +35,7 @@ | |||
247 | 35 | inlineCallbacks, | 35 | inlineCallbacks, |
248 | 36 | succeed, | 36 | succeed, |
249 | 37 | ) | 37 | ) |
250 | 38 | from twisted.python import context | ||
251 | 38 | from zope.interface.verify import verifyObject | 39 | from zope.interface.verify import verifyObject |
252 | 39 | 40 | ||
253 | 40 | 41 | ||
254 | @@ -213,6 +214,16 @@ | |||
255 | 213 | mac = factory.getRandomMACAddress(b"-") | 214 | mac = factory.getRandomMACAddress(b"-") |
256 | 214 | config_path = compose_config_path(mac) | 215 | config_path = compose_config_path(mac) |
257 | 215 | backend = TFTPBackend(self.make_dir(), b"http://example.com/") | 216 | backend = TFTPBackend(self.make_dir(), b"http://example.com/") |
258 | 217 | # python-tx-tftp sets up call context so that backends can discover | ||
259 | 218 | # more about the environment in which they're running. | ||
260 | 219 | call_context = { | ||
261 | 220 | "local": ( | ||
262 | 221 | factory.getRandomIPAddress(), | ||
263 | 222 | factory.getRandomPort()), | ||
264 | 223 | "remote": ( | ||
265 | 224 | factory.getRandomIPAddress(), | ||
266 | 225 | factory.getRandomPort()), | ||
267 | 226 | } | ||
268 | 216 | 227 | ||
269 | 217 | @partial(self.patch, backend, "get_config_reader") | 228 | @partial(self.patch, backend, "get_config_reader") |
270 | 218 | def get_config_reader(params): | 229 | def get_config_reader(params): |
271 | @@ -220,9 +231,16 @@ | |||
272 | 220 | params_json_reader = BytesReader(params_json) | 231 | params_json_reader = BytesReader(params_json) |
273 | 221 | return succeed(params_json_reader) | 232 | return succeed(params_json_reader) |
274 | 222 | 233 | ||
276 | 223 | reader = yield backend.get_reader(config_path) | 234 | reader = yield context.call( |
277 | 235 | call_context, backend.get_reader, config_path) | ||
278 | 224 | output = reader.read(10000) | 236 | output = reader.read(10000) |
280 | 225 | expected_params = dict(mac=mac) | 237 | # The addresses provided by python-tx-tftp in the call context are |
281 | 238 | # passed over the wire as address:port strings. | ||
282 | 239 | expected_params = { | ||
283 | 240 | "mac": mac, | ||
284 | 241 | "local": call_context["local"][0], # address only. | ||
285 | 242 | "remote": call_context["remote"][0], # address only. | ||
286 | 243 | } | ||
287 | 226 | observed_params = json.loads(output) | 244 | observed_params = json.loads(output) |
288 | 227 | self.assertEqual(expected_params, observed_params) | 245 | self.assertEqual(expected_params, observed_params) |
289 | 228 | 246 | ||
290 | 229 | 247 | ||
291 | === modified file 'src/provisioningserver/tftp.py' | |||
292 | --- src/provisioningserver/tftp.py 2012-10-03 08:50:17 +0000 | |||
293 | +++ src/provisioningserver/tftp.py 2012-10-30 09:11:22 +0000 | |||
294 | @@ -34,6 +34,7 @@ | |||
295 | 34 | IReader, | 34 | IReader, |
296 | 35 | ) | 35 | ) |
297 | 36 | from tftp.errors import FileNotFound | 36 | from tftp.errors import FileNotFound |
298 | 37 | from twisted.python.context import get | ||
299 | 37 | from twisted.web.client import getPage | 38 | from twisted.web.client import getPage |
300 | 38 | import twisted.web.error | 39 | import twisted.web.error |
301 | 39 | from zope.interface import implementer | 40 | from zope.interface import implementer |
302 | @@ -211,6 +212,11 @@ | |||
303 | 211 | for key, value in config_file_match.groupdict().items() | 212 | for key, value in config_file_match.groupdict().items() |
304 | 212 | if value is not None | 213 | if value is not None |
305 | 213 | } | 214 | } |
306 | 215 | # Send the local and remote endpoint addresses. | ||
307 | 216 | local_host, local_port = get("local", (None, None)) | ||
308 | 217 | params["local"] = local_host | ||
309 | 218 | remote_host, remote_port = get("remote", (None, None)) | ||
310 | 219 | params["remote"] = remote_host | ||
311 | 214 | d = self.get_config_reader(params) | 220 | d = self.get_config_reader(params) |
312 | 215 | d.addErrback(self.get_page_errback, file_name) | 221 | d.addErrback(self.get_page_errback, file_name) |
313 | 216 | return d | 222 | return d |
306 + # Send the local and remote endpoint addresses.
307 + local_host, local_port = get("local", (None, None))
308 + if local_host is not None:
309 + params["local"] = local_host
310 + remote_host, remote_port = get("remote", (None, None))
311 + if remote_host is not None:
312 + params["remote"] = remote_host
As discussed, get rid of the conditionals here, just try to poke in the IP data since it's mandatory in the API. If it fails, we want to fail early.
Everything else looks tip top!