Merge lp:~trapnine/maas/power-package-checks into lp:~maas-committers/maas/trunk

Proposed by Jeffrey C Jones
Status: Merged
Approved by: Jeffrey C Jones
Approved revision: no longer in the source branch.
Merged at revision: 4386
Proposed branch: lp:~trapnine/maas/power-package-checks
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 804 lines (+319/-23)
15 files modified
src/maasserver/api/tests/test_enlistment.py (+6/-0)
src/maasserver/api/tests/test_node.py (+3/-0)
src/maasserver/clusterrpc/power.py (+45/-3)
src/maasserver/clusterrpc/power_parameters.py (+2/-0)
src/maasserver/models/node.py (+25/-2)
src/maasserver/models/tests/test_node.py (+30/-1)
src/maasserver/static/js/angular/controllers/node_details.js (+36/-5)
src/maasserver/static/partials/node-details.html (+15/-4)
src/provisioningserver/power/change.py (+11/-0)
src/provisioningserver/power/query.py (+11/-0)
src/provisioningserver/power/tests/test_change.py (+48/-3)
src/provisioningserver/power/tests/test_query.py (+45/-4)
src/provisioningserver/rpc/cluster.py (+21/-0)
src/provisioningserver/rpc/clusterservice.py (+11/-0)
src/provisioningserver/rpc/tests/test_clusterservice.py (+10/-1)
To merge this branch: bzr merge lp:~trapnine/maas/power-package-checks
Reviewer Review Type Date Requested Status
Gavin Panella (community) Approve
Review via email: mp+274533@code.launchpad.net

Commit message

MAAS will check that power driver packages are installed before performing operations that require them. Messages in the UI will list the required packages and on which cluster they should be installed. They can be found:
  - on the node list page when mass operations are performed
  - on the node detail page before allowing selected operations

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

I have lots of comments and things do need changing but ultimately this is good stuff.

I have a deeper reservation about it though: why are we not installing the packages we need for the drivers we ship in the first place? It's seems daft that we tell people afterwards that they need to install some other package.

To take the AMT driver as an example, I can see that the packages it needs -- amtterm and wsmancli -- are in universe. MAAS, which is in main, cannot depend on those. That means that MAAS, whatever it might claim, does NOT actually ship with the AMT driver in main.

Instead of investing energy in this approach we ought to finally make drivers pluggable (which is NOT that hard) and ship the AMT driver as a package in universe too. OR we should get amtterm and wsmancli into main.

That doesn't reflect badly on your code or your efforts, but we have an opportunity to do this in a much better way.

review: Needs Fixing
Revision history for this message
Jeffrey C Jones (trapnine) wrote :

FYI This feature was requested in https://bugs.launchpad.net/maas/+bug/1381000.

Revision history for this message
Jeffrey C Jones (trapnine) wrote :

It would definitely be cool if the drivers were pluggable instead of 'hardcoded' into MAAS. These plugins could include the client drivers, and be optional, allowing the customer to pick what they want installed.

Until then, though, I wouldn't want every power package MAAS supports installed on all my clusterd's. I may never buy AMT boxes, use virsh VM's, etc. Also, some of the power drivers (like for the IBM Power7 stuff) require privately hosted software to work, so we'd still need to check for that client software.

It seems sane to check for the real commands we're literally about to run before we attempt to do what will change power. If these commands aren't there, we tell the user what packages to install. It's not a big departure code-design-wise, and it follows the existing driver paradigm.

My only reservation with it is that it's a hard gate - there's not way to override MAAS's objection and start/commission/release, except by changing the power type or installing the desired package to make MAAS happy - maybe there should be a 'manual' power type?) Then again, the commands really won't work if clusterd can't find these binaries.

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

> It would definitely be cool if the drivers were pluggable instead of
> 'hardcoded' into MAAS. These plugins could include the client drivers,
> and be optional, allowing the customer to pick what they want
> installed.

They're only hard-coded into the cluster; the region always asks the
cluster for power driver information, so making the drivers pluggable
only needs to be solved on the cluster.

>
> Until then, though, I wouldn't want every power package MAAS supports
> installed on all my clusterd's. I may never buy AMT boxes, use virsh
> VM's, etc. Also, some of the power drivers (like for the IBM Power7
> stuff) require privately hosted software to work, so we'd still need
> to check for that client software.

If all drivers were pluggable then we could have packages like:

   maas-driver-ipmi (in main)
   maas-driver-amt (in universe)
   maas-driver-ibm-power-7 (in partner, or provided separately)

maas-driver-amt, for example, would depend on amtterm and wsmancli. The
IPMI driver might be installed by default, but the others would be
optional. Getting fancy, we could search for packages named
maas-driver-* and inform users of them via the UI.

>
> It seems sane to check for the real commands we're literally about to
> run before we attempt to do what will change power. If these commands
> aren't there, we tell the user what packages to install. It's not a
> big departure code-design-wise, and it follows the existing driver
> paradigm.

This can be done pre-flight. The drivers can decide to also check at
power-on, power-off, or power-query time, but they'll crash anyway when
they come to use those commands... at which point we can show the user
the pre-flight errors.

For AMT, for example, I need only amtterm, not wsmancli, so the absence
of the latter is a warning only.

>
> My only reservation with it is that it's a hard gate - there's not way
> to override MAAS's objection and start/commission/release, except by
> changing the power type or installing the desired package to make MAAS
> happy - maybe there should be a 'manual' power type?) Then again, the
> commands really won't work if clusterd can't find these binaries.

I agree, and I think this goes back to my point. If a driver appears in
MAAS's UI then it should be fully installed. It should not be possible
to have a half-installed driver, like we can have now. However, once
half-installed drivers are no longer possible we would need to inform
users that other drivers are available, and the steps needed to install
them.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

I agree with all of Gavin's points. It would be a huge improvement and using python-stevedore will allow us this ability.

But for 1.9, we need to fix this bug and doing what is described here does not fit. Lets track this in a bug and see if we can get time in 2.0 to fix this.

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

Here's my rough suggestion for what to do now:

Change PowerDriverCheck to something like:

  from provisioningserver.rpc.arguments import (
      CompressedAmpList,
      Choice,
  )

  class PowerDriverCheck(amp.Command):
      """Check power driver on cluster for missing packages.

      :since: 1.9
      """

      arguments = [
          (b"power_type", amp.Unicode()),
      ]
      response = [
          (b"messages": CompressedAmpList([
              (b"severity", Choice({
                   "INFO": b"INFO",
                   "WARNING": b"WARNING",
                   "ERROR": b"ERROR",
               })),
              (b"message", amp.Unicode()),
          ])),
      ]
      errors = {
          UnknownPowerType: (
              b"UnknownPowerType"),
          NotImplementedError: (
              b"NotImplementedError"),
      }

Use this to display warnings and errors in the UI. Informational
messages can be logged but perhaps not displayed.

In drivers, look for the commands (or other resources) needed rather
than packages, and report about each of them.

This is simple enough to cover most/any scenarios, not just missing
packages or commands, but there's enough structure that we COULD use
WARNING or ERROR messages when measuring and reporting on MAAS's health
(a feature planned for this cycle).

Next, raise PowerActionFail; dispense with PowerDriverMissingPackages.
This will remove several code-paths.

Then, don't gate use of power-on, power-off, or power-query on the
outcome of the above.

If calling PowerDriverCheck frequently one day causes problems we can do
it periodically and cache the results in the database, but that's not an
issue right now.

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

One more comment.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

The code does look at binaries, but reports the package that provides that binary. I don't think that needs changing here.

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

> The code does look at binaries, but reports the package that provides that
> binary. I don't think that needs changing here.

Okay. That's compatible with the change I suggested to PowerDriverCheck. The difference is that the message is composed on the cluster.

Revision history for this message
Jeffrey C Jones (trapnine) wrote :

> > The code does look at binaries, but reports the package that provides that
> > binary. I don't think that needs changing here.
>
> Okay. That's compatible with the change I suggested to PowerDriverCheck. The
> difference is that the message is composed on the cluster.

Nice, I'll remove that new exception type.

However, I purposely sent symbolic info to the region so the smarts of rendering a meaningful message can be in the UI (to achieve various designs).

If we are ever going to localize, it belongs in the html/frontend so the clusterd need never be locale aware. Each user on a MAAS can have their browser in a different language without informing any backend. If we're NEVER going to localize, then shunting raw error messages across is 'future proof', but the web client won't know what it's showing and many formatting / design concepts will not be possible.

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

[...]
> However, I purposely sent symbolic info to the region so the smarts of
> rendering a meaningful message can be in the UI (to achieve various
> designs).
>
> If we are ever going to localize, it belongs in the html/frontend so
> the clusterd need never be locale aware. Each user on a MAAS can have
> their browser in a different language without informing any backend.
> If we're NEVER going to localize, then shunting raw error messages
> across is 'future proof', but the web client won't know what it's
> showing and many formatting / design concepts will not be possible.

I agree that that would be the right thing to do, but the likelihood of
localising MAAS any day soon is near zero. However, dealing with non-deb
environments like Ubuntu Core is imminent, as is dealing with more
general ideas of health.

Your other point about formatting and design is a good one.

Once the RPC call spec has been put into a released product we can't
change it. It'll be outdated almost as soon as it's released, and we'll
need to revisit this problem again.

Mmm. It's six of one, half-a-dozen of the other. There's a danger I'm
over-thinking this. If you go with what you've got we can rev the call
once we know more. I've talked myself into agreement with you :)

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

A few more comments but they're all minor. Nice stuff!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/tests/test_enlistment.py'
2--- src/maasserver/api/tests/test_enlistment.py 2015-10-09 20:37:59 +0000
3+++ src/maasserver/api/tests/test_enlistment.py 2015-10-20 18:00:40 +0000
4@@ -55,6 +55,7 @@
5 super(EnlistmentAPITest, self).setUp()
6 self.patch_autospec(node_module, 'power_on_node')
7 self.patch_autospec(node_module, 'power_off_node')
8+ self.patch_autospec(node_module, 'power_driver_check')
9 self.patch_autospec(Node, 'start_transition_monitor')
10
11 def test_POST_new_creates_node(self):
12@@ -369,6 +370,7 @@
13 super(NodeHostnameEnlistmentTest, self).setUp()
14 self.patch_autospec(node_module, 'power_on_node')
15 self.patch_autospec(node_module, 'power_off_node')
16+ self.patch_autospec(node_module, 'power_driver_check')
17 self.patch_autospec(Node, 'start_transition_monitor')
18
19 scenarios = [
20@@ -480,6 +482,7 @@
21 super(NonAdminEnlistmentAPITest, self).setUp()
22 self.patch_autospec(node_module, 'power_on_node')
23 self.patch_autospec(node_module, 'power_off_node')
24+ self.patch_autospec(node_module, 'power_driver_check')
25
26 def test_POST_non_admin_creates_node_in_declared_state(self):
27 # Upon non-admin enlistment, a node goes into the New
28@@ -508,6 +511,7 @@
29 super(AnonymousEnlistmentAPITest, self).setUp()
30 self.patch_autospec(node_module, 'power_on_node')
31 self.patch_autospec(node_module, 'power_off_node')
32+ self.patch_autospec(node_module, 'power_driver_check')
33
34 def test_POST_accept_not_allowed(self):
35 # An anonymous user is not allowed to accept an anonymously
36@@ -574,6 +578,7 @@
37 super(SimpleUserLoggedInEnlistmentAPITest, self).setUp()
38 self.patch_autospec(node_module, 'power_on_node')
39 self.patch_autospec(node_module, 'power_off_node')
40+ self.patch_autospec(node_module, 'power_driver_check')
41
42 def test_POST_accept_not_allowed(self):
43 # An non-admin user is not allowed to accept an anonymously
44@@ -683,6 +688,7 @@
45 super(AdminLoggedInEnlistmentAPITest, self).setUp()
46 self.patch_autospec(node_module, 'power_on_node')
47 self.patch_autospec(node_module, 'power_off_node')
48+ self.patch_autospec(node_module, 'power_driver_check')
49 self.patch_autospec(Node, 'start_transition_monitor')
50
51 def test_POST_new_sets_power_type_if_admin(self):
52
53=== modified file 'src/maasserver/api/tests/test_node.py'
54--- src/maasserver/api/tests/test_node.py 2015-10-09 14:36:18 +0000
55+++ src/maasserver/api/tests/test_node.py 2015-10-20 18:00:40 +0000
56@@ -91,6 +91,7 @@
57 super(NodeAnonAPITest, self).setUp()
58 self.patch(node_module, 'power_on_node')
59 self.patch(node_module, 'power_off_node')
60+ self.patch(node_module, 'power_driver_check')
61
62 def test_anon_nodes_GET(self):
63 # Anonymous requests to the API without a specified operation
64@@ -140,6 +141,7 @@
65 super(TestNodeAPI, self).setUp()
66 self.patch(node_module, 'power_on_node')
67 self.patch(node_module, 'power_off_node')
68+ self.patch(node_module, 'power_driver_check')
69
70 def test_handler_path(self):
71 self.assertEqual(
72@@ -1619,6 +1621,7 @@
73 '''
74
75 def test_POST_start_returns_error_when_static_ips_exhausted(self):
76+ self.patch(node_module, 'power_driver_check')
77 node = factory.make_Node_with_Interface_on_Subnet(
78 owner=self.logged_in_user, status=NODE_STATUS.ALLOCATED,
79 architecture=make_usable_architecture(self))
80
81=== modified file 'src/maasserver/clusterrpc/power.py'
82--- src/maasserver/clusterrpc/power.py 2015-05-07 18:14:38 +0000
83+++ src/maasserver/clusterrpc/power.py 2015-10-20 18:00:40 +0000
84@@ -18,16 +18,20 @@
85 ]
86
87 from functools import partial
88+import logging
89
90 from maasserver.rpc import getClientFor
91 from provisioningserver.logger import get_maas_logger
92 from provisioningserver.rpc.cluster import (
93+ PowerDriverCheck,
94 PowerOff,
95 PowerOn,
96 )
97 from provisioningserver.utils.twisted import asynchronous
98-
99-
100+from twisted.protocols.amp import UnhandledCommand
101+
102+
103+logger = logging.getLogger(__name__)
104 maaslog = get_maas_logger("power")
105
106
107@@ -52,7 +56,7 @@
108 def call_power_command(client, **kwargs):
109 return client(command, **kwargs)
110
111- maaslog.debug("%s: Asking cluster to power on node.", hostname)
112+ maaslog.debug("%s: Asking cluster to power on/off node.", hostname)
113 d = getClientFor(cluster_uuid).addCallback(
114 call_power_command, system_id=system_id, hostname=hostname,
115 power_type=power_info.power_type,
116@@ -73,3 +77,41 @@
117
118 power_off_node = partial(power_node, PowerOff)
119 power_on_node = partial(power_node, PowerOn)
120+
121+
122+@asynchronous(timeout=30)
123+def power_driver_check(cluster_uuid, power_type):
124+ """Call PowerDriverCheck on the given cluster and wait for response.
125+
126+ :param cluster-uuid: The UUID of the cluster to check.
127+ :param power_type: The power type to check.
128+ :return: A list of missing power drivers for the power_type, if any.
129+
130+ :raises NoConnectionsAvailable: When no connections to the node's
131+ cluster are available for use.
132+ :raises UnknownPowerType: When the requested power type is not known
133+ to the cluster.
134+ :raises NotImplementedError: When the power driver hasn't implemented
135+ the missing packages check.
136+ :raises TimeoutError: If a response has not been received within 30
137+ seconds.
138+ """
139+ def get_missing_packages(client):
140+ d = client(PowerDriverCheck, power_type=power_type)
141+ d.addCallbacks(extract_missing_packages, ignore_unhandled_command)
142+ return d
143+
144+ def extract_missing_packages(response):
145+ return response["missing_packages"]
146+
147+ def ignore_unhandled_command(failure):
148+ failure.trap(UnhandledCommand)
149+ # The region hasn't been upgraded to support this method yet, so give
150+ # up. Returning an empty list indicates that the power driver is OK, so
151+ # the power attempt will continue and any errors will be caught later.
152+ logger.warn(
153+ "Unable to query cluster for power packages. Cluster does not"
154+ "support the PowerDriverCheck RPC method. Returning OK.")
155+ return []
156+
157+ return getClientFor(cluster_uuid).addCallback(get_missing_packages)
158
159=== modified file 'src/maasserver/clusterrpc/power_parameters.py'
160--- src/maasserver/clusterrpc/power_parameters.py 2015-10-01 22:48:50 +0000
161+++ src/maasserver/clusterrpc/power_parameters.py 2015-10-20 18:00:40 +0000
162@@ -99,6 +99,8 @@
163 :param fields: The fields that make up the parameters for the power
164 type. Will be validated against
165 POWER_TYPE_PARAMETER_FIELD_SCHEMA.
166+ :param missing_packages: System packages that must be installed on
167+ the cluster before the power type can be used.
168 :type fields: list of `make_json_field` results.
169 :param parameters_set: An existing list of power type parameters to
170 mutate.
171
172=== modified file 'src/maasserver/models/node.py'
173--- src/maasserver/models/node.py 2015-10-16 17:27:09 +0000
174+++ src/maasserver/models/node.py 2015-10-20 18:00:40 +0000
175@@ -54,6 +54,7 @@
176 from maasserver import DefaultMeta
177 from maasserver.clusterrpc.dhcp import remove_host_maps
178 from maasserver.clusterrpc.power import (
179+ power_driver_check,
180 power_off_node,
181 power_on_node,
182 )
183@@ -132,7 +133,10 @@
184 )
185 from provisioningserver.logger import get_maas_logger
186 from provisioningserver.power import QUERY_POWER_TYPES
187-from provisioningserver.power.poweraction import UnknownPowerType
188+from provisioningserver.power.poweraction import (
189+ PowerActionFail,
190+ UnknownPowerType,
191+)
192 from provisioningserver.utils.enum import map_enum_reverse
193 from provisioningserver.utils.twisted import (
194 asynchronous,
195@@ -1456,6 +1460,23 @@
196 power_type, power_params,
197 )
198
199+ def confirm_power_driver_operable(self):
200+ power_type = self.get_effective_power_type()
201+ missing_packages = power_driver_check(self.nodegroup.uuid, power_type)
202+ if len(missing_packages) > 0:
203+ missing_packages = sorted(missing_packages)
204+ if len(missing_packages) > 2:
205+ missing_packages = [", ".join(
206+ missing_packages[:-1]), missing_packages[-1]]
207+ package_list = " and ".join(missing_packages)
208+ raise PowerActionFail(
209+ "Power control software is missing from the cluster "
210+ "controller. To proceed, "
211+ "install the %s package%s on the %s cluster." % (
212+ package_list,
213+ "s" if len(missing_packages) > 1 else "",
214+ self.nodegroup.cluster_name))
215+
216 def acquire(
217 self, user, token=None, agent_name='', comment=None):
218 """Mark commissioned node as acquired by the given user and token."""
219@@ -2276,7 +2297,7 @@
220 # You can't start a node you don't own unless you're an admin.
221 raise PermissionDenied()
222 event = EVENT_TYPES.REQUEST_NODE_START
223- # if status is ALLOCATED, this start is actually for a deplyment
224+ # if status is ALLOCATED, this start is actually for a deployment
225 if self.status == NODE_STATUS.ALLOCATED:
226 event = EVENT_TYPES.REQUEST_NODE_START_DEPLOYMENT
227 self._register_request_event(
228@@ -2336,6 +2357,7 @@
229 # Everything we've done up to this point is still valid;
230 # this is not an error state.
231 return None
232+ self.confirm_power_driver_operable()
233
234 def pc_power_on_node(system_id, hostname, nodegroup_uuid, power_info):
235 d = power_on_node(system_id, hostname, nodegroup_uuid, power_info)
236@@ -2389,6 +2411,7 @@
237 # node we don't know how to stop isn't an error state, but
238 # it's a no-op.
239 return None
240+ self.confirm_power_driver_operable()
241
242 # Smuggle in a hint about how to power-off the self.
243 power_info.power_parameters['power_off_mode'] = stop_mode
244
245=== modified file 'src/maasserver/models/tests/test_node.py'
246--- src/maasserver/models/tests/test_node.py 2015-10-16 17:27:09 +0000
247+++ src/maasserver/models/tests/test_node.py 2015-10-20 18:00:40 +0000
248@@ -142,6 +142,10 @@
249
250 class TestNode(MAASServerTestCase):
251
252+ def setUp(self):
253+ super(TestNode, self).setUp()
254+ self.patch_autospec(node_module, 'power_driver_check')
255+
256 def disable_node_query(self):
257 self.addCleanup(node_query.enable)
258 node_query.disable()
259@@ -3378,6 +3382,7 @@
260 self.useFixture(RegionEventLoopFixture("rpc"))
261 self.useFixture(RunningEventLoopFixture())
262 self.rpc_fixture = self.useFixture(MockLiveRegionToClusterRPCFixture())
263+ self.patch_autospec(node_module, 'power_driver_check')
264
265 def prepare_rpc_to_cluster(self, nodegroup):
266 protocol = self.rpc_fixture.makeCluster(
267@@ -3485,7 +3490,17 @@
268 node._start_transition_monitor_async,
269 MockCalledOnceWith(ANY, node.hostname))
270
271- def test_start_logs_user_request(self):
272+ def test__node_start_checks_power_driver(self):
273+ self.patch_autospec(node_module, "power_on_node")
274+ user = factory.make_User()
275+ node = self.make_acquired_node_with_interface(user)
276+ driver_check = self.patch_autospec(node_module, "power_driver_check")
277+ with post_commit_hooks:
278+ node.start(user)
279+ self.expectThat(driver_check, MockCalledOnceWith(
280+ node.nodegroup.uuid, node.get_effective_power_type()))
281+
282+ def test__start_logs_user_request(self):
283 admin = factory.make_admin()
284 node = factory.make_Node(status=NODE_STATUS.NEW, owner=admin)
285 self.patch(node, '_start')
286@@ -3597,6 +3612,10 @@
287 class TestNode_Stop(MAASServerTestCase):
288 """Tests for Node.stop()."""
289
290+ def setUp(self):
291+ super(TestNode_Stop, self).setUp()
292+ self.patch_autospec(node_module, 'power_driver_check')
293+
294 def make_node_with_interface(
295 self, user, nodegroup=None, power_type="virsh"):
296 node = factory.make_Node_with_Interface_on_Subnet(
297@@ -3625,6 +3644,16 @@
298 node.system_id, node.hostname, node.nodegroup.uuid,
299 expected_power_info))
300
301+ def test__node_stop_checks_power_driver(self):
302+ self.patch_autospec(node_module, "power_off_node")
303+ user = factory.make_User()
304+ node = self.make_node_with_interface(user)
305+ driver_check = self.patch_autospec(node_module, "power_driver_check")
306+ with post_commit_hooks:
307+ node.stop(user, factory.make_name('stop-mode'))
308+ self.expectThat(driver_check, MockCalledOnceWith(
309+ node.nodegroup.uuid, node.get_effective_power_type()))
310+
311 def test__does_not_stop_nodes_the_user_cannot_edit(self):
312 power_off_node = self.patch_autospec(node_module, "power_off_node")
313 owner = factory.make_User()
314
315=== modified file 'src/maasserver/static/js/angular/controllers/node_details.js'
316--- src/maasserver/static/js/angular/controllers/node_details.js 2015-10-01 22:48:50 +0000
317+++ src/maasserver/static/js/angular/controllers/node_details.js 2015-10-20 18:00:40 +0000
318@@ -903,6 +903,25 @@
319 }
320 };
321
322+ // true if power error prevents the provided action
323+ $scope.hasActionPowerError = function(actionName) {
324+ if(!$scope.hasPowerError()) {
325+ return false; // no error, no need to check state
326+ }
327+ // these states attempt to manipulate power
328+ var powerChangingStates = [
329+ 'commission',
330+ 'deploy',
331+ 'on',
332+ 'off',
333+ 'release'
334+ ];
335+ if(actionName && powerChangingStates.indexOf(actionName) > -1) {
336+ return true;
337+ }
338+ return false;
339+ };
340+
341 // Check to see if the power type has any missing system packages.
342 $scope.hasPowerError = function() {
343 if(angular.isObject($scope.power.type)) {
344@@ -912,13 +931,25 @@
345 }
346 };
347
348- // Returns an array of missing system packages.
349- $scope.getPowerError = function() {
350+ // Returns a formatted string of missing system packages.
351+ $scope.getPowerErrors = function() {
352+ var i;
353+ var result = "";
354 if(angular.isObject($scope.power.type)) {
355- return $scope.power.type.missing_packages;
356- } else {
357- return false;
358+ var packages = $scope.power.type.missing_packages;
359+ packages.sort();
360+ for(i = 0; i < packages.length; i++) {
361+ result += packages[i];
362+ if(i+2 < packages.length) {
363+ result += ", ";
364+ }
365+ else if(i+1 < packages.length) {
366+ result += " and ";
367+ }
368+ }
369+ result += packages.length > 1 ? " packages" : " package";
370 }
371+ return result;
372 };
373
374 // Load all the required managers.
375
376=== modified file 'src/maasserver/static/partials/node-details.html'
377--- src/maasserver/static/partials/node-details.html 2015-10-19 17:56:35 +0000
378+++ src/maasserver/static/partials/node-details.html 2015-10-20 18:00:40 +0000
379@@ -48,7 +48,7 @@
380 <!-- XXX blake_r 2015-02-19 - Need to add e2e test. -->
381 <div class="page-header__dropdown ng-hide" data-ng-show="actionOption">
382 <!-- XXX blake_r 2015-02-19 - Need to add e2e test. -->
383- <div class="page-header__feedback ng-hide" data-ng-hide="isActionError() || isDeployError() || isSSHKeyError()">
384+ <div class="page-header__feedback ng-hide" data-ng-hide="isActionError() || isDeployError() || isSSHKeyError() || hasActionPowerError(actionOption.name)">
385 <form class="form-inline">
386 <div class="eight-col no-margin-bottom ng-hide" data-ng-show="actionOption.name === 'commission'">
387 <div class="inline four-col">
388@@ -104,6 +104,17 @@
389 </div>
390 </div>
391
392+ <div class="page-header__feedback ng-hide" data-ng-show="hasActionPowerError(actionOption.name)">
393+ <p class="page-header__feedback-message info">
394+ Node cannot be {$ actionOption.sentence $}, because power control software for the
395+ node is missing from its cluster controller. To proceed, install the
396+ {$ getPowerErrors() $} on the {$ node.nodegroup.cluster_name $} cluster.
397+ </p>
398+ <div class="right">
399+ <a href="" class="margin-right" data-ng-click="actionCancel()">Cancel</a>
400+ </div>
401+ </div>
402+
403 <div class="page-header__feedback ng-hide" data-ng-show="isSSHKeyError()">
404 <p class="page-header__feedback-message info">
405 Node cannot be {$ actionOption.sentence $}, because an SSH key has not been added to your account. To add an SSH key, visit <a href="account/prefs/">your account page</a>.
406@@ -270,9 +281,9 @@
407 <div class="twelve-col ng-hide error" data-ng-show="hasPowerError()">
408 <ul class="flash-messages">
409 <li class="flash-messages__item error">
410- To control power with this power type, install the
411- '<span ng-repeat="error in getPowerError()">{$ error $}{$$last ? '' : ', '$}</span>'
412- package(s) on cluster '{$ node.nodegroup.name $}'
413+ Power control software for this power type is missing from the
414+ cluster controller. To proceed, install the {$ getPowerErrors() $}
415+ on the {$ node.nodegroup.cluster_name $} cluster.
416 </li>
417 </ul>
418 </div>
419
420=== modified file 'src/provisioningserver/power/change.py'
421--- src/provisioningserver/power/change.py 2015-09-25 14:39:44 +0000
422+++ src/provisioningserver/power/change.py 2015-10-20 18:00:40 +0000
423@@ -22,6 +22,7 @@
424 from provisioningserver.drivers.power import (
425 DEFAULT_WAITING_POLICY,
426 get_error_message,
427+ power_drivers_by_name,
428 PowerDriverRegistry,
429 )
430 from provisioningserver.events import (
431@@ -200,6 +201,16 @@
432 assert power_change in ('on', 'off'), (
433 "Unknown power change: %s" % power_change)
434
435+ power_driver = power_drivers_by_name.get(power_type)
436+ if power_driver is None:
437+ raise poweraction.PowerActionFail(
438+ "Unknown power_type '%s'" % power_type)
439+ missing_packages = power_driver.detect_missing_packages()
440+ if len(missing_packages):
441+ raise poweraction.PowerActionFail(
442+ "'%s' package(s) are not installed" % " ".join(
443+ missing_packages))
444+
445 # There should be one and only one power change for each system ID.
446 if system_id in power.power_action_registry:
447 current_power_change, d = power.power_action_registry[system_id]
448
449=== modified file 'src/provisioningserver/power/query.py'
450--- src/provisioningserver/power/query.py 2015-10-02 11:18:07 +0000
451+++ src/provisioningserver/power/query.py 2015-10-20 18:00:40 +0000
452@@ -23,6 +23,7 @@
453 from provisioningserver import power
454 from provisioningserver.drivers.power import (
455 DEFAULT_WAITING_POLICY,
456+ power_drivers_by_name,
457 PowerDriverRegistry,
458 )
459 from provisioningserver.events import (
460@@ -107,6 +108,16 @@
461 # Capture errors as we go along.
462 exc_info = None, None, None
463
464+ power_driver = power_drivers_by_name.get(power_type)
465+ if power_driver is None:
466+ raise poweraction.PowerActionFail(
467+ "Unknown power_type '%s'" % power_type)
468+ missing_packages = power_driver.detect_missing_packages()
469+ if len(missing_packages):
470+ raise poweraction.PowerActionFail(
471+ "'%s' package(s) are not installed" % ", ".join(
472+ missing_packages))
473+
474 if power.is_driver_available(power_type):
475 # New-style power drivers handle retries for themselves, so we only
476 # ever call them once.
477
478=== modified file 'src/provisioningserver/power/tests/test_change.py'
479--- src/provisioningserver/power/tests/test_change.py 2015-10-01 19:33:36 +0000
480+++ src/provisioningserver/power/tests/test_change.py 2015-10-20 18:00:40 +0000
481@@ -41,6 +41,7 @@
482 from provisioningserver.drivers.power import (
483 DEFAULT_WAITING_POLICY,
484 get_error_message as get_driver_error_message,
485+ power_drivers_by_name,
486 PowerDriverRegistry,
487 PowerError,
488 )
489@@ -553,6 +554,9 @@
490 def setUp(self):
491 super(TestMaybeChangePowerState, self).setUp()
492 self.patch(power, 'power_action_registry', {})
493+ for power_driver in power_drivers_by_name.values():
494+ self.patch(
495+ power_driver, "detect_missing_packages").return_value = []
496 self.useFixture(EventTypesAllRegistered())
497 do_not_pause(self)
498
499@@ -566,8 +570,9 @@
500
501 def test_always_returns_deferred(self):
502 clock = Clock()
503+ power_type = random.choice(power.QUERY_POWER_TYPES)
504 d = power.change.maybe_change_power_state(
505- sentinel.system_id, sentinel.hostname, sentinel.power_type,
506+ sentinel.system_id, sentinel.hostname, power_type,
507 random.choice(("on", "off")), sentinel.context, clock=clock)
508 self.assertThat(d, IsInstance(Deferred))
509
510@@ -592,6 +597,46 @@
511 self.assertEqual({}, power.power_action_registry)
512
513 @inlineCallbacks
514+ def test_checks_missing_packages(self):
515+ self.patch_methods_using_rpc()
516+
517+ system_id = factory.make_name('system_id')
518+ hostname = factory.make_name('hostname')
519+ power_type = random.choice(power.QUERY_POWER_TYPES)
520+ power_change = random.choice(['on', 'off'])
521+ context = {
522+ factory.make_name('context-key'): factory.make_name('context-val')
523+ }
524+ power_driver = power_drivers_by_name.get(power_type)
525+ detect_packages = self.patch_autospec(
526+ power_driver, "detect_missing_packages")
527+ detect_packages.return_value = []
528+ yield power.change.maybe_change_power_state(
529+ system_id, hostname, power_type, power_change, context)
530+ reactor.runUntilCurrent() # Run all delayed calls.
531+ self.assertThat(detect_packages, MockCalledOnceWith())
532+
533+ @inlineCallbacks
534+ def test_errors_when_missing_packages(self):
535+ self.patch_methods_using_rpc()
536+
537+ system_id = factory.make_name('system_id')
538+ hostname = factory.make_name('hostname')
539+ power_type = random.choice(power.QUERY_POWER_TYPES)
540+ power_change = random.choice(['on', 'off'])
541+ context = {
542+ factory.make_name('context-key'): factory.make_name('context-val')
543+ }
544+ power_driver = power_drivers_by_name.get(power_type)
545+ detect_packages = self.patch_autospec(
546+ power_driver, "detect_missing_packages")
547+ detect_packages.return_value = ['gone']
548+ with ExpectedException(poweraction.PowerActionFail):
549+ yield power.change.maybe_change_power_state(
550+ system_id, hostname, power_type, power_change, context)
551+ self.assertThat(detect_packages, MockCalledOnceWith())
552+
553+ @inlineCallbacks
554 def test_errors_when_change_conflicts_with_in_progress_change(self):
555 system_id = factory.make_name('system_id')
556 hostname = factory.make_name('hostname')
557@@ -673,7 +718,7 @@
558
559 system_id = factory.make_name('system_id')
560 hostname = factory.make_hostname()
561- power_type = sentinel.power_type
562+ power_type = random.choice(power.QUERY_POWER_TYPES)
563 power_change = random.choice(['on', 'off'])
564 context = sentinel.context
565
566@@ -702,7 +747,7 @@
567
568 system_id = factory.make_name('system_id')
569 hostname = factory.make_hostname()
570- power_type = sentinel.power_type
571+ power_type = random.choice(power.QUERY_POWER_TYPES)
572 power_change = random.choice(['on', 'off'])
573 context = sentinel.context
574
575
576=== modified file 'src/provisioningserver/power/tests/test_query.py'
577--- src/provisioningserver/power/tests/test_query.py 2015-10-02 11:18:07 +0000
578+++ src/provisioningserver/power/tests/test_query.py 2015-10-20 18:00:40 +0000
579@@ -48,6 +48,7 @@
580 from provisioningserver import power
581 from provisioningserver.drivers.power import (
582 DEFAULT_WAITING_POLICY,
583+ power_drivers_by_name,
584 PowerDriverRegistry,
585 )
586 from provisioningserver.events import EVENT_TYPES
587@@ -127,7 +128,7 @@
588 protocol, io = self.patch_rpc_methods()
589 d = power.query.power_query_failure(
590 system_id, hostname, Failure(Exception(message)))
591- # This blocks until the deferred is complete
592+ # This blocks until the deferred is complete.
593 io.flush()
594 self.assertIsNone(extract_result(d))
595 self.assertThat(
596@@ -145,6 +146,9 @@
597 super(TestPowerQuery, self).setUp()
598 self.useFixture(EventTypesAllRegistered())
599 self.patch(power.query, "deferToThread", maybeDeferred)
600+ for power_driver in power_drivers_by_name.values():
601+ self.patch(
602+ power_driver, "detect_missing_packages").return_value = []
603
604 def patch_rpc_methods(self, return_value={}, side_effect=None):
605 fixture = self.useFixture(MockClusterToRegionRPCFixture())
606@@ -164,6 +168,12 @@
607 factory.make_name('context-key'): factory.make_name('context-val')
608 }
609 self.patch(power, 'is_driver_available').return_value = False
610+
611+ power_driver = power_drivers_by_name.get(power_type)
612+ detect_packages = self.patch_autospec(
613+ power_driver, "detect_missing_packages")
614+ detect_packages.return_value = []
615+
616 # Patch the power action utility so that it says the node is
617 # in on/off power state.
618 power_action, execute = patch_PowerAction(
619@@ -172,9 +182,10 @@
620
621 d = power.query.get_power_state(
622 system_id, hostname, power_type, context)
623- # This blocks until the deferred is complete
624+ # This blocks until the deferred is complete.
625 io.flush()
626 self.assertEqual(power_state, extract_result(d))
627+ self.assertThat(detect_packages, MockCalledOnceWith())
628 self.assertThat(
629 execute,
630 MockCallsMatch(
631@@ -183,6 +194,32 @@
632 ),
633 )
634
635+ def test_get_power_state_fails_for_missing_packages(self):
636+ system_id = factory.make_name('system_id')
637+ hostname = factory.make_name('hostname')
638+ power_type = random.choice(power.QUERY_POWER_TYPES)
639+ power_state = random.choice(['on', 'off'])
640+ context = {
641+ factory.make_name('context-key'): factory.make_name('context-val')
642+ }
643+ self.patch(power, 'is_driver_available').return_value = False
644+ # Patch the power action utility so that it says the node is
645+ # in on/off power state.
646+ power_action, execute = patch_PowerAction(
647+ self, return_value=power_state)
648+ _, markNodeBroken, io = self.patch_rpc_methods()
649+
650+ power_driver = power_drivers_by_name.get(power_type)
651+ detect_packages = self.patch_autospec(
652+ power_driver, "detect_missing_packages")
653+ detect_packages.return_value = ['gone']
654+
655+ d = power.query.get_power_state(
656+ system_id, hostname, power_type, context)
657+
658+ self.assertThat(detect_packages, MockCalledOnceWith())
659+ return assert_fails_with(d, poweraction.PowerActionFail)
660+
661 def test_get_power_state_returns_unknown_for_certain_power_types(self):
662 system_id = factory.make_name('system_id')
663 hostname = factory.make_name('hostname')
664@@ -217,7 +254,7 @@
665
666 d = power.query.get_power_state(
667 system_id, hostname, power_type, context)
668- # This blocks until the deferred is complete
669+ # This blocks until the deferred is complete.
670 io.flush()
671 self.assertEqual(power_state, extract_result(d))
672 self.assertThat(
673@@ -320,7 +357,7 @@
674 system_id, hostname, power_type, context, clock=clock)
675 for newcalls, waiting_time in calls_and_pause:
676 calls.extend(newcalls)
677- # This blocks until the deferred is complete
678+ # This blocks until the deferred is complete.
679 io.flush()
680 self.assertThat(execute, MockCallsMatch(*calls))
681 clock.advance(waiting_time)
682@@ -332,6 +369,7 @@
683 scenarios = tuple(
684 (power_type, {
685 "power_type": power_type,
686+ "power_driver": power_drivers_by_name.get(power_type),
687 "func": ( # Function to invoke driver.
688 "perform_power_driver_query"
689 if power_type in PowerDriverRegistry
690@@ -367,6 +405,9 @@
691 send_event_node = self.patch_autospec(power.query, "send_event_node")
692 send_event_node.return_value = succeed(None)
693
694+ self.patch(
695+ self.power_driver, "detect_missing_packages").return_value = []
696+
697 system_id = factory.make_name('system_id')
698 hostname = factory.make_name('hostname')
699 context = sentinel.context
700
701=== modified file 'src/provisioningserver/rpc/cluster.py'
702--- src/provisioningserver/rpc/cluster.py 2015-09-10 19:19:24 +0000
703+++ src/provisioningserver/rpc/cluster.py 2015-10-20 18:00:40 +0000
704@@ -29,6 +29,7 @@
705 "PowerOff",
706 "PowerOn",
707 "PowerQuery",
708+ "PowerDriverCheck",
709 "ValidateLicenseKey",
710 ]
711
712@@ -187,6 +188,26 @@
713 }
714
715
716+class PowerDriverCheck(amp.Command):
717+ """Check power driver on cluster for missing packages
718+
719+ :since: 1.9
720+ """
721+
722+ arguments = [
723+ (b"power_type", amp.Unicode()),
724+ ]
725+ response = [
726+ (b"missing_packages", amp.ListOf(amp.Unicode())),
727+ ]
728+ errors = {
729+ UnknownPowerType: (
730+ b"UnknownPowerType"),
731+ NotImplementedError: (
732+ b"NotImplementedError"),
733+ }
734+
735+
736 class GetPreseedData(amp.Command):
737 """Get OS-specific preseed data.
738
739
740=== modified file 'src/provisioningserver/rpc/clusterservice.py'
741--- src/provisioningserver/rpc/clusterservice.py 2015-09-30 07:29:27 +0000
742+++ src/provisioningserver/rpc/clusterservice.py 2015-10-20 18:00:40 +0000
743@@ -41,9 +41,11 @@
744 from provisioningserver.drivers.hardware.ucsm import probe_and_enlist_ucsm
745 from provisioningserver.drivers.hardware.virsh import probe_virsh_and_enlist
746 from provisioningserver.drivers.hardware.vmware import probe_vmware_and_enlist
747+from provisioningserver.drivers.power import power_drivers_by_name
748 from provisioningserver.logger.log import get_maas_logger
749 from provisioningserver.network import discover_networks
750 from provisioningserver.power.change import maybe_change_power_state
751+from provisioningserver.power.poweraction import UnknownPowerType
752 from provisioningserver.power.query import get_power_state
753 from provisioningserver.rpc import (
754 cluster,
755@@ -272,6 +274,15 @@
756 d.addCallback(lambda x: {'state': x})
757 return d
758
759+ @cluster.PowerDriverCheck.responder
760+ def power_driver_check(self, power_type):
761+ """Return a list of missing power driver packages, if any."""
762+ driver = power_drivers_by_name.get(power_type)
763+ if driver is None:
764+ raise UnknownPowerType(
765+ "No driver found for power type '%s'" % power_type)
766+ return {"missing_packages": driver.detect_missing_packages()}
767+
768 @cluster.ConfigureDHCPv4.responder
769 def configure_dhcpv4(self, omapi_key, subnet_configs):
770 server = dhcp.DHCPv4Server(omapi_key)
771
772=== modified file 'src/provisioningserver/rpc/tests/test_clusterservice.py'
773--- src/provisioningserver/rpc/tests/test_clusterservice.py 2015-10-02 13:27:07 +0000
774+++ src/provisioningserver/rpc/tests/test_clusterservice.py 2015-10-20 18:00:40 +0000
775@@ -63,6 +63,7 @@
776 OperatingSystem,
777 OperatingSystemRegistry,
778 )
779+from provisioningserver.drivers.power import power_drivers_by_name
780 from provisioningserver.network import discover_networks
781 from provisioningserver.power import QUERY_POWER_TYPES
782 from provisioningserver.power.poweraction import (
783@@ -1587,12 +1588,20 @@
784 report_power_state = self.patch(
785 power_module.query, "report_power_state")
786
787+ power_type = random.choice(QUERY_POWER_TYPES)
788 arguments = {
789 'system_id': factory.make_name('system'),
790 'hostname': factory.make_name('hostname'),
791- 'power_type': random.choice(QUERY_POWER_TYPES),
792+ 'power_type': power_type,
793 'context': factory.make_name('context'),
794 }
795+
796+ # Make sure power driver doesn't check for installed packages.
797+ power_driver = power_drivers_by_name.get(power_type)
798+ if power_driver:
799+ self.patch_autospec(
800+ power_driver, "detect_missing_packages").return_value = []
801+
802 observed = yield call_responder(
803 Cluster(), cluster.PowerQuery, arguments)
804 self.assertEqual({'state': state}, observed)