Merge lp:~allenap/maas/anon-power-setting into lp:maas/trunk

Proposed by Gavin Panella on 2012-10-04
Status: Merged
Approved by: Gavin Panella on 2012-10-08
Approved revision: 1174
Merged at revision: 1208
Proposed branch: lp:~allenap/maas/anon-power-setting
Merge into: lp:maas/trunk
Diff against target: 325 lines (+139/-33)
4 files modified
src/maasserver/api.py (+32/-1)
src/maasserver/tests/test_api.py (+103/-6)
src/metadataserver/api.py (+2/-24)
src/metadataserver/tests/test_api.py (+2/-2)
To merge this branch: bzr merge lp:~allenap/maas/anon-power-setting
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve on 2012-10-05
Julian Edwards (community) 2012-10-04 Approve on 2012-10-05
Review via email: mp+128127@code.launchpad.net

Commit Message

Allows power parameters to be supplied at enlistment.

To post a comment you must log in.
review: Approve
MAAS Lander (maas-lander) wrote :

The Jenkins job https://jenkins.qa.ubuntu.com/job/maas-merger-quantal/117/console reported an error when processing this lp:~allenap/maas/anon-power-setting branch.
Not merging it.

MAAS Lander (maas-lander) wrote :

The Jenkins job https://jenkins.qa.ubuntu.com/job/maas-merger-quantal/119/console reported an error when processing this lp:~allenap/maas/anon-power-setting branch.
Not merging it.

lp:~allenap/maas/anon-power-setting updated on 2012-10-05
1173. By Gavin Panella on 2012-10-05

Rewrite store_node_power_parameters; it was fairly broken, and was untested.

1174. By Gavin Panella on 2012-10-05

Fix test fallout.

Raphaël Badin (rvb) wrote :

Looks good, thanks for spotting the problem and fixing this Gavin!

[0]

def store_node_power_parameters(node, request):

I wonder if it would not be more clear to change the signature of this method to store_node_power_parameters(node, power_type, power_parameters) because there is no real reason to pass the whole request around. Maybe store_node_power_parameters(node, data) (when data=request.POST) ?

[1]

+ self.assertEqual(power_type, self.node.power_type)
+ self.assertEqual("", self.node.power_parameters)
+ self.save.assert_called_once_with()

I think it would be cleaner to test the behavior here and:
- not patch the save method
- test that reload_object(node).power_type == the_new_power_type and reload_object(node).power_parameters == ""

Same remark in a few other places.

[2]

+ self.assertRaises(
+ MAASAPIBadRequest, store_node_power_parameters,
+ self.node, self.request)
+ self.save.assert_has_calls([])

Again, instead of 'self.save.assert_has_calls([])', I'd test that the node has not been changed. But maybe it's a matter of taste :).

[3]

+ power_types = map_enum(POWER_TYPE).values()
+ if power_type in power_types:
+ node.power_type = power_type
+ else:
+ raise MAASAPIBadRequest("Bad power_type '%s'" % power_type)

51 + except ValueError:
52 + raise MAASAPIBadRequest("Failed to parse JSON power_parameters")

This should probably be a ValidationError({'power_type': [error_message]}) / ValidationError({'power_parameters': [error_message]})

Rarg, all of this is kinda ugly, that's exactly why we should use forms to take care of validation. Django forms are not perfect, but they are still much better than doing all of this manually. Of course, this can be refined later ( "# Hack in the power parameters here." -> that's really what it says in the tin :)).

[4]

194 + [node] = Node.objects.filter(hostname=hostname)

I'd use "node = Node.objects.get(hostname=hostname)": is (a little bit) more clear.

review: Approve
Andres Rodriguez (andreserl) wrote :

Wouldn't it be easier leave the metadataserver api method as it is (to not break commissioning), and simply allow the power parameters to be passed on enlistment the same way it is with the rest of the parameters?

Gavin Panella (allenap) wrote :

On 5 October 2012 17:09, Andres Rodriguez <email address hidden> wrote:
> Wouldn't it be easier leave the metadataserver api method as it is (to not break commissioning), and simply allow the power parameters to be passed on enlistment the same way it is with the rest of the parameters?

That's kind of what store_node_power_parameters() does, but if there's
a better way of doing it I'm interested to know; this is not an area
where I have much expertise.

As to leaving the commissioning thing alone, I have mixed feelings. On
one hand it's broken, by validating against the wrong list of choices,
and on the other I don't want to accidentally break something that's
working.

Are there any tests for the commissioning scripts, the ones that run
on the node? They should prevent us from breaking this contract.

Andres Rodriguez (andreserl) wrote :

What do you mean by evaluating against the wrong list of choices? It evaluates against the correct power types, which is what we need t evaluate. This has been tested correctly (passing incorrect power_type).

No there are no tests for the commissioning scripts.. not an easy thing to do. IMHO much more complex than adding tests for maas-import-pxe-files.

Gavin Panella (allenap) wrote :

> What do you mean by evaluating against the wrong list of choices? It evaluates
> against the correct power types, which is what we need t evaluate. This has
> been tested correctly (passing incorrect power_type).

It checks that the given power type, converted to upper-case, is a
member of map_enum(POWER_TYPES). The result of map_enum(POWER_TYPES)
is an attribute-name->enum-value mapping, so it's checking that the
given parameter matches one of the enum's attribute names, i.e. a
Python variable name. It should be checking (without changing case)
against map_enum(POWER_TYPES).values().

> No there are no tests for the commissioning scripts.. not an easy thing to do.
> IMHO much more complex than adding tests for maas-import-pxe-files.

No, granted, an integration test of that would be a beast. We could
add unit tests for individual parts of those scripts though, for
example the part that sends the commissioning result, but I don't
think we'll have time for that before releasing 12.10.

Julian Edwards (julian-edwards) wrote :

On Friday 05 October 2012 21:50:27 you wrote:
> It checks that the given power type, converted to upper-case, is a
> member of map_enum(POWER_TYPES). The result of map_enum(POWER_TYPES)
> is an attribute-name->enum-value mapping, so it's checking that the
> given parameter matches one of the enum's attribute names, i.e. a
> Python variable name. It should be checking (without changing case)
> against map_enum(POWER_TYPES).values().

What's the practical difference? (Given what map_enum is doing)

> > No there are no tests for the commissioning scripts.. not an easy thing to
> > do. IMHO much more complex than adding tests for maas-import-pxe-files.
> No, granted, an integration test of that would be a beast. We could
> add unit tests for individual parts of those scripts though, for
> example the part that sends the commissioning result, but I don't
> think we'll have time for that before releasing 12.10.

Sadly no, this will go down as tech debt.

Gavin Panella (allenap) wrote :

On 8 October 2012 00:51, Julian Edwards <...> wrote:
> On Friday 05 October 2012 21:50:27 you wrote:
>> It checks that the given power type, converted to upper-case, is a
>> member of map_enum(POWER_TYPES). The result of map_enum(POWER_TYPES)
>> is an attribute-name->enum-value mapping, so it's checking that the
>> given parameter matches one of the enum's attribute names, i.e. a
>> Python variable name. It should be checking (without changing case)
>> against map_enum(POWER_TYPES).values().
>
> What's the practical difference?  (Given what map_enum is doing)

It's looking in the keys:

  >>> map_enum(POWER_TYPE).keys()
  ['DEFAULT', 'IPMI', 'VIRSH', 'WAKE_ON_LAN']

but it should be looking in the values:

  >>> map_enum(POWER_TYPE).values()
  [u'', u'ipmi', u'virsh', u'ether_wake']

Julian Edwards (julian-edwards) wrote :

On Monday 08 October 2012 08:52:43 you wrote:
> On 8 October 2012 00:51, Julian Edwards <...> wrote:
> > On Friday 05 October 2012 21:50:27 you wrote:
> >> It checks that the given power type, converted to upper-case, is a
> >> member of map_enum(POWER_TYPES). The result of map_enum(POWER_TYPES)
> >> is an attribute-name->enum-value mapping, so it's checking that the
> >> given parameter matches one of the enum's attribute names, i.e. a
> >> Python variable name. It should be checking (without changing case)
> >> against map_enum(POWER_TYPES).values().
> >
> > What's the practical difference? (Given what map_enum is doing)
>
> It's looking in the keys:
>
> >>> map_enum(POWER_TYPE).keys()
> ['DEFAULT', 'IPMI', 'VIRSH', 'WAKE_ON_LAN']
>
> but it should be looking in the values:
>
> >>> map_enum(POWER_TYPE).values()
> [u'', u'ipmi', u'virsh', u'ether_wake']

Right. As we discussed in the hangout, it's ok to just change this as
fortunately the only thing using it so far is the impi stuff where the key is
the same as the value.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api.py'
2--- src/maasserver/api.py 2012-10-05 08:04:58 +0000
3+++ src/maasserver/api.py 2012-10-05 14:13:24 +0000
4@@ -75,6 +75,7 @@
5 "TagsHandler",
6 "pxeconfig",
7 "render_api_docs",
8+ "store_node_power_parameters",
9 ]
10
11 from base64 import b64decode
12@@ -158,6 +159,7 @@
13 compose_preseed_url,
14 )
15 from maasserver.server_address import get_maas_facing_server_address
16+from maasserver.utils import map_enum
17 from maasserver.utils.orm import get_one
18 from piston.handler import (
19 AnonymousBaseHandler,
20@@ -167,6 +169,7 @@
21 from piston.models import Token
22 from piston.resource import Resource
23 from piston.utils import rc
24+from provisioningserver.enum import POWER_TYPE
25 from provisioningserver.kernel_opts import KernelParameters
26
27
28@@ -418,6 +421,31 @@
29 )
30
31
32+def store_node_power_parameters(node, request):
33+ """Store power parameters in request.
34+
35+ The parameters should be JSON, passed with key `power_parameters`.
36+ """
37+ power_type = request.POST.get("power_type", None)
38+ if power_type is None:
39+ return
40+
41+ power_types = map_enum(POWER_TYPE).values()
42+ if power_type in power_types:
43+ node.power_type = power_type
44+ else:
45+ raise MAASAPIBadRequest("Bad power_type '%s'" % power_type)
46+
47+ power_parameters = request.POST.get("power_parameters", None)
48+ if power_parameters and not power_parameters.isspace():
49+ try:
50+ node.power_parameters = json.loads(power_parameters)
51+ except ValueError:
52+ raise MAASAPIBadRequest("Failed to parse JSON power_parameters")
53+
54+ node.save()
55+
56+
57 class NodeHandler(OperationsHandler):
58 """Manage individual Nodes."""
59 create = None # Disable create.
60@@ -586,7 +614,10 @@
61 Form = get_node_create_form(request.user)
62 form = Form(altered_query_data)
63 if form.is_valid():
64- return form.save()
65+ node = form.save()
66+ # Hack in the power parameters here.
67+ store_node_power_parameters(node, request)
68+ return node
69 else:
70 raise ValidationError(form.errors)
71
72
73=== modified file 'src/maasserver/tests/test_api.py'
74--- src/maasserver/tests/test_api.py 2012-10-05 08:04:58 +0000
75+++ src/maasserver/tests/test_api.py 2012-10-05 14:13:24 +0000
76@@ -45,6 +45,7 @@
77 extract_oauth_key_from_auth_header,
78 get_oauth_token,
79 get_overrided_query_dict,
80+ store_node_power_parameters,
81 )
82 from maasserver.enum import (
83 ARCHITECTURE,
84@@ -57,7 +58,10 @@
85 NODEGROUP_STATUS,
86 NODEGROUPINTERFACE_MANAGEMENT,
87 )
88-from maasserver.exceptions import Unauthorized
89+from maasserver.exceptions import (
90+ MAASAPIBadRequest,
91+ Unauthorized,
92+ )
93 from maasserver.fields import mac_error_msg
94 from maasserver.forms import DEFAULT_ZONE_NAME
95 from maasserver.models import (
96@@ -221,6 +225,71 @@
97 self.assertEqual([data_value], results.getlist(key))
98
99
100+class TestStoreNodeParameters(TestCase):
101+ """Tests for `store_node_power_parameters`."""
102+
103+ def setUp(self):
104+ super(TestStoreNodeParameters, self).setUp()
105+ self.node = factory.make_node()
106+ self.save = self.patch(self.node, "save")
107+ self.request = Mock()
108+
109+ def test_power_type_not_given(self):
110+ # When power_type is not specified, nothing happens.
111+ self.request.POST = {}
112+ store_node_power_parameters(self.node, self.request)
113+ self.assertEqual(POWER_TYPE.DEFAULT, self.node.power_type)
114+ self.assertEqual("", self.node.power_parameters)
115+ self.save.assert_has_calls([])
116+
117+ def test_power_type_set_but_no_parameters(self):
118+ # When power_type is valid, it is set. However, if power_parameters is
119+ # not specified, the node's power_parameters is left alone, and the
120+ # node is saved.
121+ power_type = factory.getRandomChoice(POWER_TYPE_CHOICES)
122+ self.request.POST = {"power_type": power_type}
123+ store_node_power_parameters(self.node, self.request)
124+ self.assertEqual(power_type, self.node.power_type)
125+ self.assertEqual("", self.node.power_parameters)
126+ self.save.assert_called_once_with()
127+
128+ def test_power_type_set_with_parameters(self):
129+ # When power_type is valid, and power_parameters is valid JSON, both
130+ # fields are set on the node, and the node is saved.
131+ power_type = factory.getRandomChoice(POWER_TYPE_CHOICES)
132+ power_parameters = {"foo": [1, 2, 3]}
133+ self.request.POST = {
134+ "power_type": power_type,
135+ "power_parameters": json.dumps(power_parameters),
136+ }
137+ store_node_power_parameters(self.node, self.request)
138+ self.assertEqual(power_type, self.node.power_type)
139+ self.assertEqual(power_parameters, self.node.power_parameters)
140+ self.save.assert_called_once_with()
141+
142+ def test_power_type_set_with_invalid_parameters(self):
143+ # When power_type is valid, but power_parameters is invalid JSON, the
144+ # node is not saved, and an exception is raised.
145+ power_type = factory.getRandomChoice(POWER_TYPE_CHOICES)
146+ self.request.POST = {
147+ "power_type": power_type,
148+ "power_parameters": "Not JSON.",
149+ }
150+ self.assertRaises(
151+ MAASAPIBadRequest, store_node_power_parameters,
152+ self.node, self.request)
153+ self.save.assert_has_calls([])
154+
155+ def test_invalid_power_type(self):
156+ # When power_type is invalid, the node is not saved, and an exception
157+ # is raised.
158+ self.request.POST = {"power_type": factory.make_name("bogus")}
159+ self.assertRaises(
160+ MAASAPIBadRequest, store_node_power_parameters,
161+ self.node, self.request)
162+ self.save.assert_has_calls([])
163+
164+
165 class MultipleUsersScenarios:
166 """A mixin that uses testscenarios to repeat a testcase as different
167 users.
168@@ -289,6 +358,32 @@
169 [diane] = Node.objects.filter(hostname='diane')
170 self.assertEqual(architecture, diane.architecture)
171
172+ def test_POST_new_creates_node_with_power_parameters(self):
173+ # We're setting power parameters so we disable start_commissioning to
174+ # prevent anything from attempting to issue power instructions.
175+ self.patch(Node, "start_commissioning")
176+ hostname = factory.make_name("hostname")
177+ architecture = factory.getRandomChoice(ARCHITECTURE_CHOICES)
178+ power_type = POWER_TYPE.IPMI
179+ power_parameters = {
180+ "power_user": factory.make_name("power-user"),
181+ "power_pass": factory.make_name("power-pass"),
182+ }
183+ response = self.client.post(
184+ self.get_uri('nodes/'),
185+ {
186+ 'op': 'new',
187+ 'hostname': hostname,
188+ 'architecture': architecture,
189+ 'mac_addresses': factory.getRandomMACAddress(),
190+ 'power_parameters': json.dumps(power_parameters),
191+ 'power_type': power_type,
192+ })
193+ self.assertEqual(httplib.OK, response.status_code)
194+ [node] = Node.objects.filter(hostname=hostname)
195+ self.assertEqual(power_parameters, node.power_parameters)
196+ self.assertEqual(power_type, node.power_type)
197+
198 def test_POST_new_creates_node_with_arch_only(self):
199 architecture = factory.getRandomChoice(
200 [choice for choice in ARCHITECTURE_CHOICES
201@@ -600,23 +695,25 @@
202 nodes_returned = json.loads(response.content)
203 self.assertEqual([], nodes_returned)
204
205- def test_POST_simple_user_cannot_set_power_type_and_parameters(self):
206+ def test_POST_simple_user_can_set_power_type_and_parameters(self):
207 new_power_address = factory.getRandomString()
208 response = self.client.post(
209 self.get_uri('nodes/'), {
210 'op': 'new',
211 'architecture': factory.getRandomChoice(ARCHITECTURE_CHOICES),
212 'power_type': POWER_TYPE.WAKE_ON_LAN,
213- 'power_parameters_power_address': new_power_address,
214+ 'power_parameters': json.dumps(
215+ {"power_address": new_power_address}),
216 'mac_addresses': ['AA:BB:CC:DD:EE:FF'],
217 })
218
219 node = Node.objects.get(
220 system_id=json.loads(response.content)['system_id'])
221 self.assertEqual(
222- (httplib.OK, '', POWER_TYPE.DEFAULT),
223- (response.status_code, node.power_parameters,
224- node.power_type))
225+ (httplib.OK, {"power_address": new_power_address},
226+ POWER_TYPE.WAKE_ON_LAN),
227+ (response.status_code, node.power_parameters,
228+ node.power_type))
229
230 def test_POST_returns_limited_fields(self):
231 response = self.client.post(
232
233=== modified file 'src/metadataserver/api.py'
234--- src/metadataserver/api.py 2012-10-04 09:53:04 +0000
235+++ src/metadataserver/api.py 2012-10-05 14:13:24 +0000
236@@ -18,8 +18,6 @@
237 'VersionIndexHandler',
238 ]
239
240-import json
241-
242 from django.conf import settings
243 from django.core.exceptions import PermissionDenied
244 from django.http import HttpResponse
245@@ -29,6 +27,7 @@
246 get_mandatory_param,
247 operation,
248 OperationsHandler,
249+ store_node_power_parameters,
250 )
251 from maasserver.enum import (
252 NODE_STATUS,
253@@ -49,7 +48,6 @@
254 get_enlist_userdata,
255 get_preseed,
256 )
257-from maasserver.utils import map_enum
258 from maasserver.utils.orm import get_one
259 from metadataserver.models import (
260 NodeCommissionResult,
261@@ -57,7 +55,6 @@
262 NodeUserData,
263 )
264 from piston.utils import rc
265-from provisioningserver.enum import POWER_TYPE
266
267
268 class UnknownMetadataVersion(MAASAPINotFound):
269@@ -188,25 +185,6 @@
270 contents = raw_content.decode('utf-8')
271 NodeCommissionResult.objects.store_data(node, name, contents)
272
273- def _store_power_parameters(self, node, request):
274- """Store power parameters passed back in commissioning result."""
275- type = request.POST.get("power_type", None)
276- if type is None:
277- return
278-
279- params = request.POST.get("power_parameters", None)
280-
281- type_dict = map_enum(POWER_TYPE)
282- if type.upper() not in type_dict:
283- raise MAASAPIBadRequest("Bad power_type '%s'" % type)
284- node.power_type = type_dict[type.upper()]
285-
286- try:
287- node.power_parameters = json.loads(params)
288- except ValueError:
289- raise MAASAPIBadRequest("Failed to parse json power_parameters")
290- node.save()
291-
292 @operation(idempotent=False)
293 def signal(self, request, version=None, mac=None):
294 """Signal commissioning status.
295@@ -243,7 +221,7 @@
296 return rc.ALL_OK
297
298 self._store_commissioning_results(node, request)
299- self._store_power_parameters(node, request)
300+ store_node_power_parameters(node, request)
301
302 target_status = self.signaling_statuses.get(status)
303 if target_status in (None, node.status):
304
305=== modified file 'src/metadataserver/tests/test_api.py'
306--- src/metadataserver/tests/test_api.py 2012-10-04 11:20:46 +0000
307+++ src/metadataserver/tests/test_api.py 2012-10-05 14:13:24 +0000
308@@ -524,7 +524,7 @@
309 power_user=factory.getRandomString(),
310 power_pass=factory.getRandomString())
311 response = self.call_signal(
312- client, power_type="IPMI", power_parameters=json.dumps(params))
313+ client, power_type="ipmi", power_parameters=json.dumps(params))
314 self.assertEqual(httplib.OK, response.status_code, response.content)
315 node = reload_object(node)
316 self.assertEqual(
317@@ -552,7 +552,7 @@
318 response = self.call_signal(
319 client, power_type="ipmi", power_parameters="badjson")
320 self.assertEqual(
321- (httplib.BAD_REQUEST, "Failed to parse json power_parameters"),
322+ (httplib.BAD_REQUEST, "Failed to parse JSON power_parameters"),
323 (response.status_code, response.content))
324
325 def test_api_retrieves_node_metadata_by_mac(self):