Merge lp:~allenap/maas/tftp-rebind-on-netif-change into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 1945
Proposed branch: lp:~allenap/maas/tftp-rebind-on-netif-change
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 384 lines (+203/-58)
5 files modified
services/pserv/run (+1/-0)
src/provisioningserver/plugin.py (+4/-16)
src/provisioningserver/tests/test_plugin.py (+14/-41)
src/provisioningserver/tests/test_tftp.py (+107/-0)
src/provisioningserver/tftp.py (+77/-1)
To merge this branch: bzr merge lp:~allenap/maas/tftp-rebind-on-netif-change
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Julian Edwards (community) Approve
Review via email: mp+206060@code.launchpad.net

Commit message

Rebind TFTP services to new interfaces when pserv received SIGHUP.

Previously the TFTP services would only be bound to those interfaces configured at start time.

To post a comment you must log in.
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Are we using threads anywhere in pserv (other than spawning one after the HUP)? Signals don't play nice with threads but it may be moot if Twisted is DTRT.

Anyway, lovely change.

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

> Are we using threads anywhere in pserv (other than spawning one after the
> HUP)? Signals don't play nice with threads but it may be moot if Twisted is
> DTRT.

Nope, don't think we're using threads. However, I've been cautious;
reactor.callFromThread will DTRT whether the handler runs in the reactor
thread or outside.

>
> Anyway, lovely change.

Thanks for the review!

Revision history for this message
Raphaël Badin (rvb) wrote :

Looks good although I'm not familiar enough with twisted to understand 100% of it. At always when making important changes to a core piece of the infrastructure, I recommend building a package from this branch and testing it before landing that whole lot.

[0]

188 +class TestTFTPService(MAASTestCase):
189 +
190 + def test_tftp_service(self):

This test is a bit painful to digest: it checks a ton of stuff. Could you think of a way to split it into more focused tests?

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

> Looks good although I'm not familiar enough with twisted to understand 100% of
> it.  At always when making important changes to a core piece of the
> infrastructure, I recommend building a package from this branch and testing it
> before landing that whole lot.

This is not something I'm in the habit of doing, but I ought to.

>
> [0]
>
> 188     +class TestTFTPService(MAASTestCase):
> 189     +
> 190     +    def test_tftp_service(self):
>
> This test is a bit painful to digest: it checks a ton of stuff.  Could you
> think of a way to split it into more focused tests?

Breaking it up would mean a lot of repetition, getting it to the right
state. The liberal use of matchers will, I hope, make any failures
easier to understand.

Thanks for the re-review!

Revision history for this message
Julian Edwards (julian-edwards) wrote :

On Thursday 13 Feb 2014 14:50:55 you wrote:
> > Looks good although I'm not familiar enough with twisted to understand
> > 100% of it. At always when making important changes to a core piece of
> > the infrastructure, I recommend building a package from this branch and
> > testing it before landing that whole lot.
>
> This is not something I'm in the habit of doing, but I ought to.

FTR it's a total piece of cake to do this.

Get the packaging branch and at its top level dir type:

bzr builddeb

And that's it :) The packages will be placed in ../build-area/

You may need to edit a couple of things:
1. Set the revno in the top debian/changelog version if it's not new enough
2. For speed's sake, edit debian/rules and find the reference to lp:maas in
the get-orig-source rule and change it to use your local trunk branch.

Revision history for this message
Julian Edwards (julian-edwards) wrote :

+ # TimerService ensures that a call is made to it's target

its*

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'services/pserv/run'
2--- services/pserv/run 2013-08-30 09:20:25 +0000
3+++ services/pserv/run 2014-02-13 12:29:56 +0000
4@@ -21,6 +21,7 @@
5 # Obtain the development setting for CLUSTER_UUID.
6 . etc/demo_maas_cluster.conf
7 export CLUSTER_UUID
8+export MAAS_URL
9
10 exec $(command -v authbind && echo --deep) \
11 "${script}" --nodaemon --pidfile="" maas-pserv --config-file "${config}"
12
13=== modified file 'src/provisioningserver/plugin.py'
14--- src/provisioningserver/plugin.py 2014-02-10 17:38:08 +0000
15+++ src/provisioningserver/plugin.py 2014-02-13 12:29:56 +0000
16@@ -21,10 +21,7 @@
17 LogService,
18 OOPSService,
19 )
20-from provisioningserver.tftp import TFTPBackend
21-from provisioningserver.utils import get_all_interface_addresses
22-from tftp.protocol import TFTP
23-from twisted.application import internet
24+from provisioningserver.tftp import TFTPService
25 from twisted.application.internet import (
26 TCPClient,
27 TCPServer,
28@@ -154,18 +151,9 @@
29
30 def _makeTFTPService(self, tftp_config):
31 """Create the dynamic TFTP service."""
32- backend = TFTPBackend(tftp_config["root"], tftp_config["generator"])
33- # Create a UDP server individually for each discovered network
34- # interface, so that we can detect the interface via which we have
35- # received a datagram.
36- tftp_services = MultiService()
37- tftp_services.setName("tftp")
38- for address in get_all_interface_addresses():
39- tftp_service = internet.UDPServer(
40- tftp_config["port"], TFTP(backend), interface=address)
41- tftp_service.setName(address)
42- tftp_service.setServiceParent(tftp_services)
43- return tftp_services
44+ tftp_service = TFTPService(**tftp_config)
45+ tftp_service.setName("tftp")
46+ return tftp_service
47
48 def _makeRPCService(self, rpc_config):
49 rpc_service = ClusterClientService(reactor)
50
51=== modified file 'src/provisioningserver/tests/test_plugin.py'
52--- src/provisioningserver/tests/test_plugin.py 2014-01-31 16:14:52 +0000
53+++ src/provisioningserver/tests/test_plugin.py 2014-02-13 12:29:56 +0000
54@@ -19,29 +19,29 @@
55
56 from maastesting.factory import factory
57 from maastesting.testcase import MAASTestCase
58-from provisioningserver import plugin
59 from provisioningserver.plugin import (
60 Options,
61 ProvisioningRealm,
62 ProvisioningServiceMaker,
63 SingleUsernamePasswordChecker,
64 )
65-from provisioningserver.tftp import TFTPBackend
66+from provisioningserver.tftp import (
67+ TFTPBackend,
68+ TFTPService,
69+ )
70 from testtools.deferredruntest import (
71 assert_fails_with,
72 AsynchronousDeferredRunTest,
73 )
74 from testtools.matchers import (
75 AfterPreprocessing,
76- AllMatch,
77 Equals,
78 IsInstance,
79 MatchesAll,
80 MatchesException,
81+ MatchesStructure,
82 Raises,
83 )
84-from tftp.protocol import TFTP
85-from twisted.application.internet import UDPServer
86 from twisted.application.service import MultiService
87 from twisted.cred.credentials import UsernamePassword
88 from twisted.cred.error import UnauthorizedLogin
89@@ -128,13 +128,6 @@
90
91 def test_tftp_service(self):
92 # A TFTP service is configured and added to the top-level service.
93- interfaces = [
94- factory.getRandomIPAddress(),
95- factory.getRandomIPAddress(),
96- ]
97- self.patch(
98- plugin, "get_all_interface_addresses",
99- lambda: interfaces)
100 config = {
101 "tftp": {
102 "generator": "http://candlemass/solitude",
103@@ -146,14 +139,9 @@
104 options["config-file"] = self.write_config(config)
105 service_maker = ProvisioningServiceMaker("Harry", "Hill")
106 service = service_maker.makeService(options)
107- tftp_services = service.getServiceNamed("tftp")
108- # The "tftp" service is a multi-service containing UDP servers for
109- # each interface defined by get_all_interface_addresses().
110- self.assertIsInstance(tftp_services, MultiService)
111- services = [
112- tftp_services.getServiceNamed(interface)
113- for interface in interfaces
114- ]
115+ tftp_service = service.getServiceNamed("tftp")
116+ self.assertIsInstance(tftp_service, TFTPService)
117+
118 expected_backend = MatchesAll(
119 IsInstance(TFTPBackend),
120 AfterPreprocessing(
121@@ -162,27 +150,12 @@
122 AfterPreprocessing(
123 lambda backend: backend.generator_url.geturl(),
124 Equals(config["tftp"]["generator"])))
125- expected_protocol = MatchesAll(
126- IsInstance(TFTP),
127- AfterPreprocessing(
128- lambda protocol: protocol.backend,
129- expected_backend))
130- expected_service = MatchesAll(
131- IsInstance(UDPServer),
132- AfterPreprocessing(
133- lambda service: len(service.args),
134- Equals(2)),
135- AfterPreprocessing(
136- lambda service: service.args[0], # port
137- Equals(config["tftp"]["port"])),
138- AfterPreprocessing(
139- lambda service: service.args[1], # protocol
140- expected_protocol))
141- self.assertThat(services, AllMatch(expected_service))
142- # Only the interface used for each service differs.
143- self.assertEqual(
144- [svc.kwargs for svc in services],
145- [{"interface": interface} for interface in interfaces])
146+
147+ self.assertThat(
148+ tftp_service, MatchesStructure(
149+ backend=expected_backend,
150+ port=Equals(config["tftp"]["port"]),
151+ ))
152
153
154 class TestSingleUsernamePasswordChecker(MAASTestCase):
155
156=== modified file 'src/provisioningserver/tests/test_tftp.py'
157--- src/provisioningserver/tests/test_tftp.py 2013-10-15 11:35:10 +0000
158+++ src/provisioningserver/tests/test_tftp.py 2014-02-13 12:29:56 +0000
159@@ -31,9 +31,22 @@
160 from provisioningserver.tftp import (
161 BytesReader,
162 TFTPBackend,
163+ TFTPService,
164 )
165 from testtools.deferredruntest import AsynchronousDeferredRunTest
166+from testtools.matchers import (
167+ AfterPreprocessing,
168+ AllMatch,
169+ Equals,
170+ IsInstance,
171+ MatchesAll,
172+ MatchesStructure,
173+ )
174 from tftp.backend import IReader
175+from tftp.protocol import TFTP
176+from twisted.application import internet
177+from twisted.application.service import MultiService
178+from twisted.internet import reactor
179 from twisted.internet.defer import (
180 inlineCallbacks,
181 succeed,
182@@ -288,3 +301,97 @@
183 self.assertEqual(fake_render_result.encode("utf-8"), output)
184 backend.render_pxe_config.assert_called_once_with(
185 kernel_params=fake_kernel_params, **fake_params)
186+
187+
188+class TestTFTPService(MAASTestCase):
189+
190+ def test_tftp_service(self):
191+ # A TFTP service is configured and added to the top-level service.
192+ interfaces = [
193+ factory.getRandomIPAddress(),
194+ factory.getRandomIPAddress(),
195+ ]
196+ self.patch(
197+ tftp_module, "get_all_interface_addresses",
198+ lambda: interfaces)
199+ example_root = self.make_dir()
200+ example_generator = "http://example.com/generator"
201+ example_port = factory.getRandomPort()
202+ tftp_service = TFTPService(
203+ root=example_root, generator=example_generator,
204+ port=example_port)
205+ tftp_service.updateServers()
206+ # The "tftp" service is a multi-service containing UDP servers for
207+ # each interface defined by get_all_interface_addresses().
208+ self.assertIsInstance(tftp_service, MultiService)
209+ # There's also a TimerService that updates the servers every 45s.
210+ self.assertThat(
211+ tftp_service.refresher, MatchesStructure.byEquality(
212+ step=45, parent=tftp_service, name="refresher",
213+ call=(tftp_service.updateServers, (), {}),
214+ ))
215+ expected_backend = MatchesAll(
216+ IsInstance(TFTPBackend),
217+ AfterPreprocessing(
218+ lambda backend: backend.base.path,
219+ Equals(example_root)),
220+ AfterPreprocessing(
221+ lambda backend: backend.generator_url.geturl(),
222+ Equals(example_generator)))
223+ expected_protocol = MatchesAll(
224+ IsInstance(TFTP),
225+ AfterPreprocessing(
226+ lambda protocol: protocol.backend,
227+ expected_backend))
228+ expected_server = MatchesAll(
229+ IsInstance(internet.UDPServer),
230+ AfterPreprocessing(
231+ lambda service: len(service.args),
232+ Equals(2)),
233+ AfterPreprocessing(
234+ lambda service: service.args[0], # port
235+ Equals(example_port)),
236+ AfterPreprocessing(
237+ lambda service: service.args[1], # protocol
238+ expected_protocol))
239+ self.assertThat(
240+ tftp_service.getServers(),
241+ AllMatch(expected_server))
242+ # Only the interface used for each service differs.
243+ self.assertItemsEqual(
244+ [svc.kwargs for svc in tftp_service.getServers()],
245+ [{"interface": interface} for interface in interfaces])
246+
247+ def test_tftp_service_rebinds_on_HUP(self):
248+ # Initial set of interfaces to bind to.
249+ interfaces = {"1.1.1.1", "2.2.2.2"}
250+ self.patch(
251+ tftp_module, "get_all_interface_addresses",
252+ lambda: interfaces)
253+
254+ tftp_service = TFTPService(
255+ root=self.make_dir(), generator="http://mighty/wind",
256+ port=factory.getRandomPort())
257+ tftp_service.updateServers()
258+
259+ # The child services of tftp_services are named after the
260+ # interface they bind to.
261+ self.assertEqual(interfaces, {
262+ server.name for server in tftp_service.getServers()
263+ })
264+
265+ # Update the set of interfaces to bind to.
266+ interfaces.add("3.3.3.3")
267+ interfaces.remove("1.1.1.1")
268+
269+ # Ask the TFTP service to update its set of servers.
270+ tftp_service.updateServers()
271+
272+ # We're in the reactor thread but we want to move the reactor
273+ # forwards, hence we need to get all explicit about it.
274+ reactor.runUntilCurrent()
275+
276+ # The interfaces now bound match the updated interfaces set.
277+ self.assertEqual(interfaces, {
278+ server.name for server in tftp_service.getServers()
279+ })
280
281=== modified file 'src/provisioningserver/tftp.py'
282--- src/provisioningserver/tftp.py 2013-10-15 11:35:10 +0000
283+++ src/provisioningserver/tftp.py 2014-02-13 12:29:56 +0000
284@@ -14,6 +14,7 @@
285 __metaclass__ = type
286 __all__ = [
287 "TFTPBackend",
288+ "TFTPService",
289 ]
290
291 import httplib
292@@ -31,12 +32,18 @@
293 from provisioningserver.enum import ARP_HTYPE
294 from provisioningserver.kernel_opts import KernelParameters
295 from provisioningserver.pxe.config import render_pxe_config
296-from provisioningserver.utils import deferred
297+from provisioningserver.utils import (
298+ deferred,
299+ get_all_interface_addresses,
300+ )
301 from tftp.backend import (
302 FilesystemSynchronousBackend,
303 IReader,
304 )
305 from tftp.errors import FileNotFound
306+from tftp.protocol import TFTP
307+from twisted.application import internet
308+from twisted.application.service import MultiService
309 from twisted.python.context import get
310 from twisted.web.client import getPage
311 import twisted.web.error
312@@ -223,3 +230,72 @@
313 d = self.get_config_reader(params)
314 d.addErrback(self.get_page_errback, file_name)
315 return d
316+
317+
318+class TFTPService(MultiService, object):
319+ """An umbrella service representing a set of running TFTP servers.
320+
321+ Creates a UDP server individually for each discovered network
322+ interface, so that we can detect the interface via which we have
323+ received a datagram.
324+
325+ It then periodically updates the servers running in case there's a
326+ change to the host machine's network configuration.
327+
328+ :ivar backend: The :class:`TFTPBackend` being used to service TFTP
329+ requests.
330+
331+ :ivar port: The port on which each server is started.
332+
333+ :ivar refresher: A :class:`TimerService` that calls
334+ ``updateServers`` periodically.
335+
336+ """
337+
338+ def __init__(self, root, port, generator):
339+ """
340+ :param root: The root directory for this TFTP server.
341+ :param port: The port on which each server should be started.
342+ :param generator: The URL to be queried for PXE configuration.
343+ """
344+ super(TFTPService, self).__init__()
345+ self.backend, self.port = TFTPBackend(root, generator), port
346+ # Establish a periodic call to self.updateServers() every 45
347+ # seconds, so that this service eventually converges on truth.
348+ # TimerService ensures that a call is made to it's target
349+ # function immediately as it's started, so there's no need to
350+ # call updateServers() from here.
351+ self.refresher = internet.TimerService(45, self.updateServers)
352+ self.refresher.setName("refresher")
353+ self.refresher.setServiceParent(self)
354+
355+ def getServers(self):
356+ """Return a set of all configured servers.
357+
358+ :rtype: :class:`set` of :class:`internet.UDPServer`
359+ """
360+ return {
361+ service for service in self
362+ if service is not self.refresher
363+ }
364+
365+ def updateServers(self):
366+ """Run a server on every interface.
367+
368+ For each configured network interface this will start a TFTP
369+ server. If called later it will bring up servers on newly
370+ configured interfaces and bring down servers on deconfigured
371+ interfaces.
372+ """
373+ addrs_established = set(service.name for service in self.getServers())
374+ addrs_desired = set(get_all_interface_addresses())
375+
376+ for address in addrs_desired - addrs_established:
377+ tftp_service = internet.UDPServer(
378+ self.port, TFTP(self.backend), interface=address)
379+ tftp_service.setName(address)
380+ tftp_service.setServiceParent(self)
381+
382+ for address in addrs_established - addrs_desired:
383+ tftp_service = self.getServiceNamed(address)
384+ tftp_service.disownServiceParent()