Merge lp:~jtv/maas/control-dhcpd 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: 2804
Proposed branch: lp:~jtv/maas/control-dhcpd
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 449 lines (+215/-45)
7 files modified
src/maasserver/tests/test_dhcp.py (+3/-4)
src/provisioningserver/dhcp/control.py (+79/-0)
src/provisioningserver/dhcp/tests/test_control.py (+103/-0)
src/provisioningserver/rpc/dhcp.py (+3/-6)
src/provisioningserver/rpc/tests/test_dhcp.py (+11/-14)
src/provisioningserver/tasks.py (+9/-11)
src/provisioningserver/tests/test_tasks.py (+7/-10)
To merge this branch: bzr merge lp:~jtv/maas/control-dhcpd
Reviewer Review Type Date Requested Status
Julian Edwards (community) Approve
Review via email: mp+232029@code.launchpad.net

Commit message

Extract functions for stopping/restarting dhcpd into their own module. We'll be using them in additional places soon.

Description of the change

This was Blake's suggestion in review of my previous branch. It makes things a bit cleaner and also, easier to test.

Jeroen

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

I'm not sure I like the plethora of restart/start/stop methods. They are only one liners anyway and considerably bloat the code with the extra docstrings and testing.

Given you're doing this layering, it indicates that the underlying "issue_service_command" is so poorly interfaced that you need to add another wrapper to make it palatable.

What's wrong with just:

call_service_command(ip_version=4, "start") etc? You can even make the subcommands consts or enums so you get:
call_service_command(IPV4, START)

which reads *much* nicer to my eyes and has considerably less code to test and maintain.

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

You can add custom test matchers or methods now, for example

self.assertIPV4Started() which just test that call_service_command was called with the right params.

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

> I'm not sure I like the plethora of restart/start/stop methods. They are only
> one liners anyway and considerably bloat the code with the extra docstrings
> and testing.

They don't bloat the code you'll normally be looking at though — only their own module, which is simple enough to figure out from the function names so you probably won't need to read it.

> Given you're doing this layering, it indicates that the underlying
> "issue_service_command" is so poorly interfaced that you need to add another
> wrapper to make it palatable.

Well, it's the other way around: the simple functions were what I wanted, and the internal helper just extracts some commonality between them.

> What's wrong with just:
>
> call_service_command(ip_version=4, "start") etc? You can even make the
> subcommands consts or enums so you get:
> call_service_command(IPV4, START)

Perhaps nothing wrong with it (apart from the obvious syntax error), but there are a few things about it I don't like. For one, the caller always knows statically whether they want to start the DHCPv4 daemon or stop the DHCPv6 daemon etc. Those things are very similar _in implementation_, but that's of no interest to the caller. In my experience turning the essentials of what the caller wants into parameters for Swiss-army-knife functions invites needless complication in itself.

For another, I wouldn't mind creating an enum for the two IP versions, but also introducing a single-purpose enum for “what do you want the dhcp service to do” to me completely negates the savings from having a single function. (Add a bit of extra confusion on top of that if the enum lives in the conventional ‘enum’ module.) Passing the parameters also weighs down tests that make use of the function: they then ought to cover not only whether the right function gets called, but also whether it gets called in the right way. Which means either verifying the parameters or integration-test the actual call.

> which reads *much* nicer to my eyes and has considerably less code to test and
> maintain.

Not “considerably less.” Four lines in total, which then effectively move into the calling code and need to be tested there, more spread out, with more specific setup, and ultimately in more places.

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

 review: approve

On Monday 25 August 2014 05:00:54 you wrote:
> They don't bloat the code you'll normally be looking at though — only their
> own module, which is simple enough to figure out from the function names so
> you probably won't need to read it.

Does this mean we can bloat any code that we don't happen to be looking at at
the particular time?

> > Given you're doing this layering, it indicates that the underlying
> > "issue_service_command" is so poorly interfaced that you need to add
> > another wrapper to make it palatable.
>
> Well, it's the other way around: the simple functions were what I wanted,
> and the internal helper just extracts some commonality between them.

OK.

> > What's wrong with just:
> >
> > call_service_command(ip_version=4, "start") etc? You can even make the
> > subcommands consts or enums so you get:
> > call_service_command(IPV4, START)
>
> Perhaps nothing wrong with it (apart from the obvious syntax error), but

It's psuedo-code ;)

> there are a few things about it I don't like. For one, the caller always
> knows statically whether they want to start the DHCPv4 daemon or stop the
> DHCPv6 daemon etc. Those things are very similar _in implementation_, but
> that's of no interest to the caller. In my experience turning the
> essentials of what the caller wants into parameters for Swiss-army-knife
> functions invites needless complication in itself.
>
> For another, I wouldn't mind creating an enum for the two IP versions, but
> also introducing a single-purpose enum for “what do you want the dhcp
> service to do” to me completely negates the savings from having a single
> function. (Add a bit of extra confusion on top of that if the enum lives
> in the conventional ‘enum’ module.) Passing the parameters also weighs
> down tests that make use of the function: they then ought to cover not only
> whether the right function gets called, but also whether it gets called in
> the right way. Which means either verifying the parameters or
> integration-test the actual call.

It's a shame you don't like using classes more, because a class property would
have been ideal. I also find that they nicely group functions like this
without exploding the number of files you have with separate modules.

> > which reads *much* nicer to my eyes and has considerably less code to test
> > and maintain.
>
> Not “considerably less.” Four lines in total, which then effectively move
> into the calling code and need to be tested there, more spread out, with
> more specific setup, and ultimately in more places.

The 4 lines go along with the repetitive docstrings as well, you can't
refactor those (well, maybe you can).

Anyway I don't want to block you so I'll approve this. I just wanted to
express some feelings about the code.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/tests/test_dhcp.py'
2--- src/maasserver/tests/test_dhcp.py 2014-08-18 10:53:21 +0000
3+++ src/maasserver/tests/test_dhcp.py 2014-08-25 04:26:47 +0000
4@@ -32,6 +32,7 @@
5 from maasserver.testing.factory import factory
6 from maasserver.testing.testcase import MAASServerTestCase
7 from maastesting.celery import CeleryFixture
8+from maastesting.matchers import MockCalledOnceWith
9 from netaddr import (
10 IPAddress,
11 IPNetwork,
12@@ -172,15 +173,13 @@
13
14 def test_configure_dhcp_restarts_dhcp_server(self):
15 self.patch(tasks, "sudo_write_file")
16- mocked_check_call = self.patch(tasks, "call_and_check")
17+ restart_dhcpv4 = self.patch(tasks, "restart_dhcpv4")
18 self.patch(settings, "DHCP_CONNECT", True)
19 nodegroup = factory.make_node_group(
20 status=NODEGROUP_STATUS.ACCEPTED,
21 management=NODEGROUPINTERFACE_MANAGEMENT.DHCP)
22 configure_dhcp(nodegroup)
23- self.assertEqual(
24- mocked_check_call.call_args[0][0],
25- ['sudo', '-n', 'service', 'maas-dhcp-server', 'restart'])
26+ self.assertThat(restart_dhcpv4, MockCalledOnceWith())
27
28 def test_configure_dhcp_is_called_with_valid_dhcp_key(self):
29 self.patch(dhcp, 'write_dhcp_config')
30
31=== added file 'src/provisioningserver/dhcp/control.py'
32--- src/provisioningserver/dhcp/control.py 1970-01-01 00:00:00 +0000
33+++ src/provisioningserver/dhcp/control.py 2014-08-25 04:26:47 +0000
34@@ -0,0 +1,79 @@
35+# Copyright 2014 Canonical Ltd. This software is licensed under the
36+# GNU Affero General Public License version 3 (see the file LICENSE).
37+
38+"""Control the MAAS DHCP daemons."""
39+
40+from __future__ import (
41+ absolute_import,
42+ print_function,
43+ unicode_literals,
44+ )
45+
46+str = None
47+
48+__metaclass__ = type
49+__all__ = [
50+ 'restart_dhcpv4',
51+ 'restart_dhcpv6',
52+ 'stop_dhcpv4',
53+ 'stop_dhcpv6',
54+ ]
55+
56+from provisioningserver.utils.shell import call_and_check
57+
58+
59+def call_service_script(ip_version, subcommand):
60+ """Issue a subcommand to one of the DHCP daemon services.
61+
62+ Shells out using `sudo`, and with the `C` locale.
63+
64+ :raise ExternalProcessError: if the restart command fails.
65+ """
66+ service_names = {
67+ 4: 'maas-dhcp-server',
68+ 6: 'maas-dhcpv6-server',
69+ }
70+ name = service_names[ip_version]
71+ call_and_check(
72+ ['sudo', '-n', 'service', name, subcommand],
73+ env={'LC_ALL': 'C'})
74+
75+
76+def restart_dhcpv4():
77+ """Restart the (IPv4) DHCP daemon.
78+
79+ Shells out using `sudo`, and with the `C` locale.
80+
81+ :raise ExternalProcessError: if the restart command fails.
82+ """
83+ call_service_script(4, 'restart')
84+
85+
86+def restart_dhcpv6():
87+ """Restart the DHCPv6 daemon.
88+
89+ Shells out using `sudo`, and with the `C` locale.
90+
91+ :raise ExternalProcessError: if the restart command fails.
92+ """
93+ call_service_script(6, 'restart')
94+
95+
96+def stop_dhcpv4():
97+ """Stop the (IPv4) DHCP daemon.
98+
99+ Shells out using `sudo`, and with the `C` locale.
100+
101+ :raise ExternalProcessError: if the restart command fails.
102+ """
103+ call_service_script(4, 'stop')
104+
105+
106+def stop_dhcpv6():
107+ """Stop the DHCPv6 daemon.
108+
109+ Shells out using `sudo`, and with the `C` locale.
110+
111+ :raise ExternalProcessError: if the restart command fails.
112+ """
113+ call_service_script(6, 'stop')
114
115=== added file 'src/provisioningserver/dhcp/tests/test_control.py'
116--- src/provisioningserver/dhcp/tests/test_control.py 1970-01-01 00:00:00 +0000
117+++ src/provisioningserver/dhcp/tests/test_control.py 2014-08-25 04:26:47 +0000
118@@ -0,0 +1,103 @@
119+# Copyright 2014 Canonical Ltd. This software is licensed under the
120+# GNU Affero General Public License version 3 (see the file LICENSE).
121+
122+"""Tests for DHCP control functions."""
123+
124+from __future__ import (
125+ absolute_import,
126+ print_function,
127+ unicode_literals,
128+ )
129+
130+str = None
131+
132+__metaclass__ = type
133+__all__ = []
134+
135+from maastesting.matchers import MockCalledOnceWith
136+from maastesting.testcase import MAASTestCase
137+from mock import ANY
138+from provisioningserver.dhcp import control
139+from provisioningserver.dhcp.control import (
140+ call_service_script,
141+ restart_dhcpv4,
142+ restart_dhcpv6,
143+ stop_dhcpv4,
144+ stop_dhcpv6,
145+ )
146+
147+
148+def patch_call_and_check(testcase):
149+ """Patch `call_and_check`."""
150+ return testcase.patch(control, 'call_and_check')
151+
152+
153+class TestCallServiceScript(MAASTestCase):
154+ """Tests for `call_service_script`."""
155+
156+ def test__fails_on_weird_IP_version(self):
157+ self.assertRaises(KeyError, call_service_script, 5, 'restart')
158+
159+ def test__controls_maas_dhcp_server_for_IP_version_4(self):
160+ call_and_check = patch_call_and_check(self)
161+ call_service_script(4, 'stop')
162+ self.assertThat(
163+ call_and_check,
164+ MockCalledOnceWith(
165+ ['sudo', '-n', 'service', 'maas-dhcp-server', 'stop'],
166+ env=ANY))
167+
168+ def test__controls_maas_dhcpv6_server_for_IP_version_6(self):
169+ call_and_check = patch_call_and_check(self)
170+ call_service_script(6, 'stop')
171+ self.assertThat(
172+ call_and_check,
173+ MockCalledOnceWith(
174+ ['sudo', '-n', 'service', 'maas-dhcpv6-server', 'stop'],
175+ env=ANY))
176+
177+ def test__sets_C_locale(self):
178+ call_and_check = patch_call_and_check(self)
179+ call_service_script(4, 'stop')
180+ self.assertThat(
181+ call_and_check,
182+ MockCalledOnceWith(ANY, env={'LC_ALL': 'C'}))
183+
184+
185+class TestControl(MAASTestCase):
186+
187+ def test_restart_dhcpv4(self):
188+ call_and_check = patch_call_and_check(self)
189+ restart_dhcpv4()
190+ self.assertThat(
191+ call_and_check,
192+ MockCalledOnceWith(
193+ ['sudo', '-n', 'service', 'maas-dhcp-server', 'restart'],
194+ env={'LC_ALL': 'C'}))
195+
196+ def test_restart_dhcpv6(self):
197+ call_and_check = patch_call_and_check(self)
198+ restart_dhcpv6()
199+ self.assertThat(
200+ call_and_check,
201+ MockCalledOnceWith(
202+ ['sudo', '-n', 'service', 'maas-dhcpv6-server', 'restart'],
203+ env={'LC_ALL': 'C'}))
204+
205+ def test_stop_dhcpv4(self):
206+ call_and_check = patch_call_and_check(self)
207+ stop_dhcpv4()
208+ self.assertThat(
209+ call_and_check,
210+ MockCalledOnceWith(
211+ ['sudo', '-n', 'service', 'maas-dhcp-server', 'stop'],
212+ env={'LC_ALL': 'C'}))
213+
214+ def test_stop_dhcpv6(self):
215+ call_and_check = patch_call_and_check(self)
216+ stop_dhcpv6()
217+ self.assertThat(
218+ call_and_check,
219+ MockCalledOnceWith(
220+ ['sudo', '-n', 'service', 'maas-dhcpv6-server', 'stop'],
221+ env={'LC_ALL': 'C'}))
222
223=== modified file 'src/provisioningserver/rpc/dhcp.py'
224--- src/provisioningserver/rpc/dhcp.py 2014-08-25 02:20:15 +0000
225+++ src/provisioningserver/rpc/dhcp.py 2014-08-25 04:26:47 +0000
226@@ -20,6 +20,7 @@
227
228 from celery.app import app_or_default
229 from provisioningserver.dhcp.config import get_config
230+from provisioningserver.dhcp.control import restart_dhcpv6
231 from provisioningserver.logger import get_maas_logger
232 from provisioningserver.omshell import Omshell
233 from provisioningserver.rpc.exceptions import (
234@@ -28,10 +29,7 @@
235 CannotRemoveHostMap,
236 )
237 from provisioningserver.utils.fs import sudo_write_file
238-from provisioningserver.utils.shell import (
239- call_and_check,
240- ExternalProcessError,
241- )
242+from provisioningserver.utils.shell import ExternalProcessError
243
244
245 maaslog = get_maas_logger("dhcp")
246@@ -66,8 +64,7 @@
247 % e.output_as_unicode)
248
249 try:
250- call_and_check(
251- ['sudo', '-n', 'service', 'maas-dhcpv6-server', 'restart'])
252+ restart_dhcpv6()
253 except ExternalProcessError as e:
254 maaslog.error(
255 "DHCPv6 server failed to restart (for network interfaces %s): %s",
256
257=== modified file 'src/provisioningserver/rpc/tests/test_dhcp.py'
258--- src/provisioningserver/rpc/tests/test_dhcp.py 2014-08-22 11:23:35 +0000
259+++ src/provisioningserver/rpc/tests/test_dhcp.py 2014-08-25 04:26:47 +0000
260@@ -42,15 +42,15 @@
261 def patch_sudo_write_file(self):
262 return self.patch(dhcp, 'sudo_write_file')
263
264- def patch_call_and_check(self):
265- return self.patch(dhcp, 'call_and_check')
266+ def patch_restart_dhcpv6(self):
267+ return self.patch(dhcp, 'restart_dhcpv6')
268
269 def patch_get_config(self):
270 return self.patch(dhcp, 'get_config')
271
272 def test__extracts_interfaces(self):
273 write_file = self.patch_sudo_write_file()
274- self.patch_call_and_check()
275+ self.patch_restart_dhcpv6()
276 subnets = [make_subnet_config() for _ in range(3)]
277 dhcp.configure_dhcpv6(factory.make_name('key'), subnets)
278 self.assertThat(
279@@ -61,7 +61,7 @@
280
281 def test__eliminates_duplicate_interfaces(self):
282 write_file = self.patch_sudo_write_file()
283- self.patch_call_and_check()
284+ self.patch_restart_dhcpv6()
285 interface = factory.make_name('interface')
286 subnets = [make_subnet_config() for _ in range(2)]
287 for subnet in subnets:
288@@ -71,7 +71,7 @@
289
290 def test__composees_dhcpv6_config(self):
291 self.patch_sudo_write_file()
292- self.patch_call_and_check()
293+ self.patch_restart_dhcpv6()
294 get_config = self.patch_get_config()
295 omapi_key = factory.make_name('key')
296 subnet = make_subnet_config()
297@@ -84,7 +84,7 @@
298
299 def test__writes_dhcpv6_config(self):
300 write_file = self.patch_sudo_write_file()
301- self.patch_call_and_check()
302+ self.patch_restart_dhcpv6()
303
304 subnet = make_subnet_config()
305 expected_config = factory.make_name('config')
306@@ -99,7 +99,7 @@
307
308 def test__writes_interfaces_file(self):
309 write_file = self.patch_sudo_write_file()
310- self.patch_call_and_check()
311+ self.patch_restart_dhcpv6()
312 dhcp.configure_dhcpv6(factory.make_name('key'), [make_subnet_config()])
313 self.assertThat(
314 write_file,
315@@ -107,17 +107,14 @@
316
317 def test__restarts_dhcpv6_server(self):
318 self.patch_sudo_write_file()
319- call_and_check = self.patch_call_and_check()
320+ call_and_check = self.patch_restart_dhcpv6()
321 dhcp.configure_dhcpv6(factory.make_name('key'), [make_subnet_config()])
322- self.assertThat(
323- call_and_check,
324- MockCalledWith(
325- ['sudo', '-n', 'service', 'maas-dhcpv6-server', 'restart']))
326+ self.assertThat(call_and_check, MockCalledWith())
327
328 def test__converts_failure_writing_file_to_CannotConfigureDHCP(self):
329 self.patch_sudo_write_file().side_effect = (
330 ExternalProcessError(1, "sudo something"))
331- self.patch_call_and_check()
332+ self.patch_restart_dhcpv6()
333 self.assertRaises(
334 exceptions.CannotConfigureDHCP,
335 dhcp.configure_dhcpv6,
336@@ -125,7 +122,7 @@
337
338 def test__converts_dhcp_restart_failure_to_CannotConfigureDHCP(self):
339 self.patch_sudo_write_file()
340- self.patch_call_and_check().side_effect = (
341+ self.patch_restart_dhcpv6().side_effect = (
342 ExternalProcessError(1, "sudo something"))
343 self.assertRaises(
344 exceptions.CannotConfigureDHCP,
345
346=== modified file 'src/provisioningserver/tasks.py'
347--- src/provisioningserver/tasks.py 2014-08-21 13:27:59 +0000
348+++ src/provisioningserver/tasks.py 2014-08-25 04:26:47 +0000
349@@ -47,6 +47,10 @@
350 config,
351 detect,
352 )
353+from provisioningserver.dhcp.control import (
354+ restart_dhcpv4,
355+ stop_dhcpv4,
356+ )
357 from provisioningserver.dhcp.leases import upload_leases
358 from provisioningserver.dns.config import (
359 DNSConfig,
360@@ -68,10 +72,7 @@
361 from provisioningserver.utils.env import environment_variables
362 from provisioningserver.utils.fs import sudo_write_file
363 from provisioningserver.utils.network import find_ip_via_arp
364-from provisioningserver.utils.shell import (
365- call_and_check,
366- ExternalProcessError,
367- )
368+from provisioningserver.utils.shell import ExternalProcessError
369
370 # For each item passed to refresh_secrets, a refresh function to give it to.
371 refresh_functions = {
372@@ -407,7 +408,7 @@
373 @log_exception_text
374 def restart_dhcp_server():
375 """Restart the DHCP server."""
376- call_and_check(['sudo', '-n', 'service', 'maas-dhcp-server', 'restart'])
377+ restart_dhcpv4()
378
379
380 # Message to put in the DHCP config file when the DHCP server gets stopped.
381@@ -432,12 +433,9 @@
382 sudo_write_file(
383 celery_config.DHCP_CONFIG_FILE, DISABLED_DHCP_SERVER)
384 try:
385- # Use LC_ALL=C because we use the returned error message when
386- # errors occur.
387- call_and_check(
388- ['sudo', '-n', 'service', 'maas-dhcp-server', 'stop'],
389- env={'LC_ALL': 'C'}
390- )
391+ # This relies on stop_dhcpv4 running in the "C" locale: if it fails,
392+ # we need to parse its error output.
393+ stop_dhcpv4()
394 except ExternalProcessError as error:
395 # Upstart issues an "Unknown instance"-type error when trying to
396 # stop an already-stopped service. Ignore this error.
397
398=== modified file 'src/provisioningserver/tests/test_tasks.py'
399--- src/provisioningserver/tests/test_tasks.py 2014-08-22 10:03:18 +0000
400+++ src/provisioningserver/tests/test_tasks.py 2014-08-25 04:26:47 +0000
401@@ -284,18 +284,15 @@
402 stdin=PIPE))
403
404 def test_restart_dhcp_server_sends_command(self):
405- self.patch(tasks, 'call_and_check')
406+ self.patch(tasks, 'restart_dhcpv4')
407 restart_dhcp_server()
408- self.assertThat(tasks.call_and_check, MockCalledOnceWith(
409- ['sudo', '-n', 'service', 'maas-dhcp-server', 'restart']))
410+ self.assertThat(tasks.restart_dhcpv4, MockCalledOnceWith())
411
412 def test_stop_dhcp_server_sends_command_and_writes_empty_config(self):
413- self.patch(tasks, 'call_and_check')
414+ self.patch(tasks, 'stop_dhcpv4')
415 self.patch(tasks, 'sudo_write_file')
416 stop_dhcp_server()
417- self.assertThat(tasks.call_and_check, MockCalledOnceWith(
418- ['sudo', '-n', 'service', 'maas-dhcp-server', 'stop'],
419- env={'LC_ALL': 'C'}))
420+ self.assertThat(tasks.stop_dhcpv4, MockCalledOnceWith())
421 self.assertThat(tasks.sudo_write_file, MockCalledOnceWith(
422 celery_config.DHCP_CONFIG_FILE, tasks.DISABLED_DHCP_SERVER))
423
424@@ -305,7 +302,7 @@
425 output = ' ' + ALREADY_STOPPED_MESSAGE + '\n'
426 exception = ExternalProcessError(
427 ALREADY_STOPPED_RETURNCODE, [], output=output)
428- self.patch(tasks, 'call_and_check', Mock(side_effect=exception))
429+ self.patch(tasks, 'stop_dhcpv4', Mock(side_effect=exception))
430 self.patch(tasks, 'sudo_write_file')
431 self.assertIsNone(stop_dhcp_server())
432
433@@ -314,7 +311,7 @@
434 returncode = ALREADY_STOPPED_RETURNCODE + 1
435 exception = ExternalProcessError(
436 returncode, [], output=ALREADY_STOPPED_MESSAGE)
437- self.patch(tasks, 'call_and_check', Mock(side_effect=exception))
438+ self.patch(tasks, 'stop_dhcpv4', Mock(side_effect=exception))
439 self.patch(tasks, 'sudo_write_file')
440 self.assertRaises(ExternalProcessError, stop_dhcp_server)
441
442@@ -323,7 +320,7 @@
443 output = factory.make_string()
444 exception = ExternalProcessError(
445 ALREADY_STOPPED_RETURNCODE, [], output=output)
446- self.patch(tasks, 'call_and_check', Mock(side_effect=exception))
447+ self.patch(tasks, 'stop_dhcpv4', Mock(side_effect=exception))
448 self.patch(tasks, 'sudo_write_file')
449 self.assertRaises(ExternalProcessError, stop_dhcp_server)
450