Merge lp:~rvb/maas/poweroff-redux into lp:~maas-committers/maas/trunk

Proposed by Raphaël Badin
Status: Merged
Approved by: Raphaël Badin
Approved revision: no longer in the source branch.
Merged at revision: 3400
Proposed branch: lp:~rvb/maas/poweroff-redux
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 499 lines (+174/-82)
12 files modified
etc/maas/templates/commissioning-user-data/user_data_poweroff.template (+28/-0)
src/maasserver/api/pxeconfig.py (+7/-12)
src/maasserver/api/tests/test_pxeconfig.py (+9/-4)
src/maasserver/models/node.py (+2/-2)
src/maasserver/preseed.py (+10/-1)
src/maasserver/tests/test_preseed.py (+25/-7)
src/metadataserver/api.py (+8/-2)
src/metadataserver/tests/test_api.py (+19/-3)
src/metadataserver/user_data/poweroff.py (+34/-0)
src/metadataserver/user_data/tests/test_poweroff.py (+31/-0)
src/provisioningserver/boot/__init__.py (+0/-16)
src/provisioningserver/boot/tests/test_boot.py (+1/-35)
To merge this branch: bzr merge lp:~rvb/maas/poweroff-redux
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+243700@code.launchpad.net

Commit message

In order to power a node off, don't use poweroff.c32/poweroff.com. Instead, bring up the node in an ephemeral environment and issue a `poweroff` command. This will enable MAAS to power off nodes without APM support.

Description of the change

I tested this manually on my NUCs and I got it through the CI (http://d-jenkins.ubuntu-ci:8080/view/MAAS/job/utopic-trunk-adt-maas-manual/30/).

Once this lands, we can update the CI code to use the NUCs we have in the CI lab and that we couldn't use so far because of the bug this branch is fixing.

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

Cool. Should work everywhere, as long as the machine can power itself off. Machines that can't aren't really MAAS's core audience (or peripheral either). Lots of comments but none of them are blockers.

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

Thanks for the review. I think I've addressed most of your comments but I confess I didn't do a full refactoring of the get_boot_purpose() code.

Revision history for this message
MAAS Lander (maas-lander) wrote :

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'etc/maas/templates/commissioning-user-data/user_data_poweroff.template'
2--- etc/maas/templates/commissioning-user-data/user_data_poweroff.template 1970-01-01 00:00:00 +0000
3+++ etc/maas/templates/commissioning-user-data/user_data_poweroff.template 2014-12-05 16:13:51 +0000
4@@ -0,0 +1,28 @@
5+#!/bin/sh
6+
7+#### script setup ######
8+PATH="$BIN_D:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
9+
10+write_poweroff_job() {
11+ cat >/etc/init/maas-poweroff.conf <<EOF
12+ description "Power-off when MAAS task is done"
13+ start on stopped cloud-final
14+ console output
15+ task
16+ script
17+ [ ! -e /tmp/block-poweroff ] || exit 0
18+ /sbin/poweroff
19+ end script
20+EOF
21+ # reload required due to lack of inotify in overlayfs (LP: #882147)
22+ initctl reload-configuration
23+}
24+
25+main() {
26+ write_poweroff_job
27+
28+ echo "Powering node off."
29+}
30+
31+main
32+exit
33
34=== modified file 'src/maasserver/api/pxeconfig.py'
35--- src/maasserver/api/pxeconfig.py 2014-11-28 15:38:33 +0000
36+++ src/maasserver/api/pxeconfig.py 2014-12-05 16:13:51 +0000
37@@ -226,24 +226,19 @@
38 osystem = Config.objects.get_config('commissioning_osystem')
39 series = Config.objects.get_config('commissioning_distro_series')
40
41- # If we are powering off the node, then we only need to return enough
42- # to turn the machine off. Calculation of the label, kernel parameters,
43- # server address, and cluster address is not needed.
44 if purpose == 'poweroff':
45- params = KernelParameters(
46- osystem="", arch=arch, subarch=subarch, release="",
47- label="", purpose=purpose, hostname=hostname, domain=domain,
48- preseed_url="", log_host="", fs_host="", extra_opts="")
49- return HttpResponse(
50- json.dumps(params._asdict()),
51- content_type="application/json")
52+ # In order to power the node off, we need to get it booted in the
53+ # commissioning environment and issue a `poweroff` command.
54+ boot_purpose = 'commissioning'
55+ else:
56+ boot_purpose = purpose
57
58 # We use as our default label the label of the most recent image for
59 # the criteria we've assembled above. If there is no latest image
60 # (which should never happen in reality but may happen in tests), we
61 # fall back to using 'no-such-image' as our default.
62 latest_image = get_boot_image(
63- nodegroup, osystem, arch, subarch, series, purpose)
64+ nodegroup, osystem, arch, subarch, series, boot_purpose)
65 if latest_image is None:
66 # XXX 2014-03-18 gmb bug=1294131:
67 # We really ought to raise an exception here so that client
68@@ -291,7 +286,7 @@
69
70 params = KernelParameters(
71 osystem=osystem, arch=arch, subarch=subarch, release=series,
72- label=label, purpose=purpose, hostname=hostname, domain=domain,
73+ label=label, purpose=boot_purpose, hostname=hostname, domain=domain,
74 preseed_url=preseed_url, log_host=server_address,
75 fs_host=cluster_address, extra_opts=extra_kernel_opts)
76
77
78=== modified file 'src/maasserver/api/tests/test_pxeconfig.py'
79--- src/maasserver/api/tests/test_pxeconfig.py 2014-11-28 15:44:21 +0000
80+++ src/maasserver/api/tests/test_pxeconfig.py 2014-12-05 16:13:51 +0000
81@@ -419,21 +419,26 @@
82 pxe_config = self.get_pxeconfig(params)
83 self.assertEqual(None, pxe_config['extra_opts'])
84
85- def test_pxeconfig_returns_poweroff_for_insane_state(self):
86+ def test_pxeconfig_returns_commissioning_for_insane_state(self):
87 mac = factory.make_MACAddress_with_Node()
88 params = self.get_default_params()
89 params['mac'] = mac.mac_address
90 pxe_config = self.get_pxeconfig(params)
91- self.assertEqual('poweroff', pxe_config['purpose'])
92+ # The 'purpose' of the PXE config is 'commissioning' here
93+ # even if the 'purpose' returned by node.get_boot_purpose
94+ # is 'poweroff' because MAAS needs to bring the machine
95+ # up in a commissioning environment in order to power
96+ # the machine down.
97+ self.assertEqual('commissioning', pxe_config['purpose'])
98
99- def test_pxeconfig_returns_poweroff_for_ready_node(self):
100+ def test_pxeconfig_returns_commissioning_for_ready_node(self):
101 mac = factory.make_MACAddress_with_Node()
102 mac.node.status = NODE_STATUS.READY
103 mac.node.save()
104 params = self.get_default_params()
105 params['mac'] = mac.mac_address
106 pxe_config = self.get_pxeconfig(params)
107- self.assertEqual('poweroff', pxe_config['purpose'])
108+ self.assertEqual('commissioning', pxe_config['purpose'])
109
110 def test_pxeconfig_returns_image_subarch_not_node_subarch(self):
111 # In the scenario such as deploying trusty on an hwe-s subarch
112
113=== modified file 'src/maasserver/models/node.py'
114--- src/maasserver/models/node.py 2014-11-26 10:11:28 +0000
115+++ src/maasserver/models/node.py 2014-12-05 16:13:51 +0000
116@@ -1396,8 +1396,8 @@
117 # otherwise boot locally.
118 if self.netboot:
119 # Avoid circular imports.
120- from maasserver.preseed import get_preseed_type_for
121- preseed_type = get_preseed_type_for(self)
122+ from maasserver.preseed import get_deploying_preseed_type_for
123+ preseed_type = get_deploying_preseed_type_for(self)
124 if preseed_type == PRESEED_TYPE.CURTIN:
125 return "xinstall"
126 else:
127
128=== modified file 'src/maasserver/preseed.py'
129--- src/maasserver/preseed.py 2014-11-07 13:16:58 +0000
130+++ src/maasserver/preseed.py 2014-12-05 16:13:51 +0000
131@@ -338,8 +338,17 @@
132 using the default installer but there is no boot image that supports
133 that method then it will boot using the fast-path installer.
134 """
135- if node.status in COMMISSIONING_LIKE_STATUSES:
136+ is_commissioning_preseed = (
137+ node.status in COMMISSIONING_LIKE_STATUSES or
138+ node.get_boot_purpose() == 'poweroff'
139+ )
140+ if is_commissioning_preseed:
141 return PRESEED_TYPE.COMMISSIONING
142+ else:
143+ return get_deploying_preseed_type_for(node)
144+
145+
146+def get_deploying_preseed_type_for(node):
147 if node.boot_type == NODE_BOOT.FASTPATH:
148 purpose_order = ['xinstall', 'install']
149 elif node.boot_type == NODE_BOOT.DEBIAN:
150
151=== modified file 'src/maasserver/tests/test_preseed.py'
152--- src/maasserver/tests/test_preseed.py 2014-10-09 16:32:04 +0000
153+++ src/maasserver/tests/test_preseed.py 2014-12-05 16:13:51 +0000
154@@ -666,7 +666,8 @@
155 self.return_windows_specific_preseed_data()
156 node = factory.make_Node(
157 nodegroup=self.rpc_nodegroup, osystem='windows',
158- architecture='amd64/generic', distro_series=self.release)
159+ architecture='amd64/generic', distro_series=self.release,
160+ status=NODE_STATUS.DEPLOYING)
161 self.configure_get_boot_images_for_node(node, 'install')
162 preseed = render_preseed(
163 node, '', osystem='windows', release=self.release)
164@@ -1207,29 +1208,42 @@
165 PRESEED_TYPE.COMMISSIONING, get_preseed_type_for(node))
166
167 def test_get_preseed_type_for_default(self):
168- node = factory.make_Node(boot_type=NODE_BOOT.DEBIAN)
169+ node = factory.make_Node(
170+ boot_type=NODE_BOOT.DEBIAN, status=NODE_STATUS.DEPLOYING)
171 self.configure_get_boot_images_for_node(node, 'install')
172 self.assertEqual(
173 PRESEED_TYPE.DEFAULT, get_preseed_type_for(node))
174
175 def test_get_preseed_type_for_curtin(self):
176- node = factory.make_Node(boot_type=NODE_BOOT.FASTPATH)
177+ node = factory.make_Node(
178+ boot_type=NODE_BOOT.FASTPATH, status=NODE_STATUS.DEPLOYING)
179 self.configure_get_boot_images_for_node(node, 'xinstall')
180 self.assertEqual(
181 PRESEED_TYPE.CURTIN, get_preseed_type_for(node))
182
183 def test_get_preseed_type_for_default_when_curtin_not_supported(self):
184- node = factory.make_Node(boot_type=NODE_BOOT.FASTPATH)
185+ node = factory.make_Node(
186+ boot_type=NODE_BOOT.FASTPATH, status=NODE_STATUS.DEPLOYING)
187 self.configure_get_boot_images_for_node(node, 'install')
188 self.assertEqual(
189 PRESEED_TYPE.DEFAULT, get_preseed_type_for(node))
190
191 def test_get_preseed_type_for_curtin_when_default_not_supported(self):
192- node = factory.make_Node(boot_type=NODE_BOOT.DEBIAN)
193+ node = factory.make_Node(
194+ boot_type=NODE_BOOT.DEBIAN, status=NODE_STATUS.DEPLOYING)
195 self.configure_get_boot_images_for_node(node, 'xinstall')
196 self.assertEqual(
197 PRESEED_TYPE.CURTIN, get_preseed_type_for(node))
198
199+ def test_get_preseed_type_for_poweroff(self):
200+ # A 'ready' node isn't supposed to be powered on and thus
201+ # will get a 'commissioning' preseed in order to be powered
202+ # down.
203+ node = factory.make_Node(
204+ boot_type=NODE_BOOT.DEBIAN, status=NODE_STATUS.READY)
205+ self.assertEqual(
206+ PRESEED_TYPE.COMMISSIONING, get_preseed_type_for(node))
207+
208
209 class TestRenderPreseedArchives(
210 PreseedRPCMixin, BootImageHelperMixin, MAASServerTestCase):
211@@ -1239,10 +1253,12 @@
212 nodes = [
213 factory.make_Node(
214 nodegroup=self.rpc_nodegroup,
215+ status=NODE_STATUS.DEPLOYING,
216 architecture=make_usable_architecture(
217 self, arch_name="i386", subarch_name="generic")),
218 factory.make_Node(
219 nodegroup=self.rpc_nodegroup,
220+ status=NODE_STATUS.DEPLOYING,
221 architecture=make_usable_architecture(
222 self, arch_name="amd64", subarch_name="generic")),
223 ]
224@@ -1314,14 +1330,16 @@
225
226 def test_get_preseed_returns_default_preseed(self):
227 node = factory.make_Node(
228- nodegroup=self.rpc_nodegroup, boot_type=NODE_BOOT.DEBIAN)
229+ nodegroup=self.rpc_nodegroup, boot_type=NODE_BOOT.DEBIAN,
230+ status=NODE_STATUS.DEPLOYING)
231 self.configure_get_boot_images_for_node(node, 'install')
232 preseed = get_preseed(node)
233 self.assertIn('preseed/late_command', preseed)
234
235 def test_get_preseed_returns_curtin_preseed(self):
236 node = factory.make_Node(
237- nodegroup=self.rpc_nodegroup, boot_type=NODE_BOOT.FASTPATH)
238+ nodegroup=self.rpc_nodegroup, boot_type=NODE_BOOT.FASTPATH,
239+ status=NODE_STATUS.DEPLOYING)
240 self.configure_get_boot_images_for_node(node, 'xinstall')
241 preseed = get_preseed(node)
242 curtin_url = reverse('curtin-metadata')
243
244=== modified file 'src/metadataserver/api.py'
245--- src/metadataserver/api.py 2014-11-12 03:27:06 +0000
246+++ src/metadataserver/api.py 2014-12-05 16:13:51 +0000
247@@ -78,6 +78,7 @@
248 from metadataserver.models.commissioningscript import (
249 BUILTIN_COMMISSIONING_SCRIPTS,
250 )
251+from metadataserver.user_data import poweroff
252 from piston.utils import rc
253 from provisioningserver.events import (
254 EVENT_DETAILS,
255@@ -424,9 +425,14 @@
256 # off to a user.
257 if node.status == NODE_STATUS.DEPLOYING:
258 node.end_deployment()
259+ # If this node is supposed to be powered off, serve the
260+ # 'poweroff' userdata.
261+ if node.get_boot_purpose() == 'poweroff':
262+ user_data = poweroff.generate_user_data(node=node)
263+ else:
264+ user_data = NodeUserData.objects.get_user_data(node)
265 return HttpResponse(
266- NodeUserData.objects.get_user_data(node),
267- mimetype='application/octet-stream')
268+ user_data, mimetype='application/octet-stream')
269 except NodeUserData.DoesNotExist:
270 logger.info(
271 "No user data registered for node named %s" % node.hostname)
272
273=== modified file 'src/metadataserver/tests/test_api.py'
274--- src/metadataserver/tests/test_api.py 2014-11-25 13:06:34 +0000
275+++ src/metadataserver/tests/test_api.py 2014-12-05 16:13:51 +0000
276@@ -62,6 +62,7 @@
277 make_list_response,
278 make_text_response,
279 MetaDataHandler,
280+ poweroff as api_poweroff,
281 UnknownMetadataVersion,
282 )
283 from metadataserver.models import (
284@@ -383,7 +384,7 @@
285 """Tests for the metadata user-data API endpoint."""
286
287 def test_user_data_view_returns_binary_data(self):
288- node = factory.make_Node()
289+ node = factory.make_Node(status=NODE_STATUS.COMMISSIONING)
290 NodeUserData.objects.set_user_data(node, sample_binary_data)
291 client = make_node_client(node)
292 response = client.get(reverse('metadata-user-data', args=['latest']))
293@@ -393,8 +394,22 @@
294 (httplib.OK, sample_binary_data),
295 (response.status_code, response.content))
296
297+ def test_poweroff_user_data_returned_if_unexpected_status(self):
298+ node = factory.make_Node(status=NODE_STATUS.READY)
299+ NodeUserData.objects.set_user_data(node, sample_binary_data)
300+ client = make_node_client(node)
301+ user_data = factory.make_name('user data').encode("ascii")
302+ self.patch(api_poweroff, 'generate_user_data').return_value = user_data
303+ response = client.get(reverse('metadata-user-data', args=['latest']))
304+ self.assertEqual('application/octet-stream', response['Content-Type'])
305+ self.assertIsInstance(response.content, bytes)
306+ self.assertEqual(
307+ (httplib.OK, user_data),
308+ (response.status_code, response.content))
309+
310 def test_user_data_for_node_without_user_data_returns_not_found(self):
311- client = make_node_client()
312+ client = make_node_client(
313+ factory.make_Node(status=NODE_STATUS.COMMISSIONING))
314 response = client.get(reverse('metadata-user-data', args=['latest']))
315 self.assertEqual(httplib.NOT_FOUND, response.status_code)
316
317@@ -938,7 +953,8 @@
318 (response.status_code, response.content))
319
320 def test_api_retrieves_node_userdata_by_mac(self):
321- mac = factory.make_MACAddress_with_Node()
322+ mac = factory.make_MACAddress_with_Node(
323+ node=factory.make_Node(status=NODE_STATUS.COMMISSIONING))
324 user_data = factory.make_string().encode('ascii')
325 NodeUserData.objects.set_user_data(mac.node, user_data)
326 url = reverse(
327
328=== added file 'src/metadataserver/user_data/poweroff.py'
329--- src/metadataserver/user_data/poweroff.py 1970-01-01 00:00:00 +0000
330+++ src/metadataserver/user_data/poweroff.py 2014-12-05 16:13:51 +0000
331@@ -0,0 +1,34 @@
332+# Copyright 2014 Canonical Ltd. This software is licensed under the
333+# GNU Affero General Public License version 3 (see the file LICENSE).
334+
335+"""Poweroff userdata generation."""
336+
337+from __future__ import (
338+ absolute_import,
339+ print_function,
340+ unicode_literals,
341+ )
342+
343+str = None
344+
345+__metaclass__ = type
346+__all__ = [
347+ "generate_user_data",
348+]
349+
350+from metadataserver.user_data.snippets import get_userdata_template_dir
351+from metadataserver.user_data.utils import (
352+ generate_user_data as _generate_user_data,
353+ )
354+
355+
356+def generate_user_data(node):
357+ """Produce the poweroff script.
358+
359+ :rtype: `bytes`
360+ """
361+ userdata_dir = get_userdata_template_dir()
362+ result = _generate_user_data(
363+ node, userdata_dir, 'user_data_poweroff.template',
364+ 'user_data_config.template')
365+ return result
366
367=== added file 'src/metadataserver/user_data/tests/test_poweroff.py'
368--- src/metadataserver/user_data/tests/test_poweroff.py 1970-01-01 00:00:00 +0000
369+++ src/metadataserver/user_data/tests/test_poweroff.py 2014-12-05 16:13:51 +0000
370@@ -0,0 +1,31 @@
371+# Copyright 2014 Canonical Ltd. This software is licensed under the
372+# GNU Affero General Public License version 3 (see the file LICENSE).
373+
374+"""Test generation of poweroff user data."""
375+
376+from __future__ import (
377+ absolute_import,
378+ print_function,
379+ unicode_literals,
380+ )
381+
382+str = None
383+
384+__metaclass__ = type
385+__all__ = []
386+
387+from maasserver.testing.factory import factory
388+from maasserver.testing.testcase import MAASServerTestCase
389+from metadataserver.user_data.poweroff import generate_user_data
390+from testtools.matchers import ContainsAll
391+
392+
393+class TestPoweroffUserData(MAASServerTestCase):
394+
395+ def test_generate_user_data_produces_poweroff_script(self):
396+ node = factory.make_Node()
397+ self.assertThat(
398+ generate_user_data(node), ContainsAll({
399+ 'Powering node off',
400+ 'poweroff',
401+ }))
402
403=== modified file 'src/provisioningserver/boot/__init__.py'
404--- src/provisioningserver/boot/__init__.py 2014-11-17 11:56:49 +0000
405+++ src/provisioningserver/boot/__init__.py 2014-12-05 16:13:51 +0000
406@@ -26,7 +26,6 @@
407 from io import BytesIO
408 from os import path
409
410-from provisioningserver import config
411 from provisioningserver.boot.tftppath import compose_image_path
412 from provisioningserver.kernel_opts import compose_kernel_command_line
413 from provisioningserver.utils import locate_config
414@@ -107,18 +106,6 @@
415 return find_mac_via_arp(remote_host)
416
417
418-def compose_poweroff_command():
419- """Composes the poweroff command depending on the version of
420- syslinux that is avaliable in the tftproot."""
421- com32_path = path.join(
422- config.BOOT_RESOURCES_STORAGE, "current", "syslinux", "poweroff.c32")
423- if path.exists(com32_path):
424- # syslinux 6 uses a com32 module for poweroff
425- return "COM32 /syslinux/poweroff.c32"
426- # syslinux 4 uses the older type
427- return "KERNEL /syslinux/poweroff.com"
428-
429-
430 class BootMethod:
431 """Skeleton for a boot method."""
432
433@@ -245,9 +232,6 @@
434 "kernel_path": kernel_path,
435 }
436
437- if kernel_params.purpose == 'poweroff':
438- namespace['poweroff'] = compose_poweroff_command()
439-
440 return namespace
441
442
443
444=== modified file 'src/provisioningserver/boot/tests/test_boot.py'
445--- src/provisioningserver/boot/tests/test_boot.py 2014-09-13 11:38:50 +0000
446+++ src/provisioningserver/boot/tests/test_boot.py 2014-12-05 16:13:51 +0000
447@@ -25,18 +25,13 @@
448 MAASTwistedRunTest,
449 )
450 import mock
451-from provisioningserver import (
452- boot,
453- config,
454- )
455+from provisioningserver import boot
456 from provisioningserver.boot import (
457 BootMethod,
458 BytesReader,
459- compose_poweroff_command,
460 gen_template_filenames,
461 get_remote_mac,
462 )
463-from provisioningserver.tests.test_kernel_opts import make_kernel_parameters
464 import tempita
465 from twisted.internet.defer import inlineCallbacks
466 from twisted.python import context
467@@ -155,32 +150,3 @@
468 self.assertRaises(
469 IOError, method.get_template,
470 *factory.make_names("purpose", "arch", "subarch"))
471-
472- def test_compose_poweroff_command_for_syslinux_6(self):
473- storage_dir = self.make_dir()
474- syslinux_path = os.path.join(storage_dir, "current", "syslinux")
475- os.makedirs(syslinux_path)
476- factory.make_file(syslinux_path, "poweroff.c32", contents=None)
477- self.patch(config, 'BOOT_RESOURCES_STORAGE', storage_dir)
478- self.assertEqual(
479- "COM32 /syslinux/poweroff.c32",
480- compose_poweroff_command())
481-
482- def test_compose_poweroff_command_for_syslinux_4(self):
483- storage_dir = self.make_dir()
484- syslinux_path = os.path.join(storage_dir, "current", "syslinux")
485- os.makedirs(syslinux_path)
486- factory.make_file(syslinux_path, "poweroff.com", contents=None)
487- self.patch(config, 'BOOT_RESOURCES_STORAGE', storage_dir)
488- self.assertEqual(
489- "KERNEL /syslinux/poweroff.com",
490- compose_poweroff_command())
491-
492- def test_compose_template_namespace_includes_poweroff(self):
493- fake_poweroff = factory.make_name('poweroff')
494- self.patch(
495- boot, 'compose_poweroff_command').return_value = fake_poweroff
496- method = FakeBootMethod()
497- kernel_params = make_kernel_parameters(purpose="poweroff")
498- namespace = method.compose_template_namespace(kernel_params)
499- self.assertEqual(fake_poweroff, namespace['poweroff'])