Merge lp:~trapnine/maas/power-package-checks into lp:~maas-committers/maas/trunk
- power-package-checks
- Merge into trunk
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 |
Related bugs: |
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
Description of the change
Jeffrey C Jones (trapnine) wrote : | # |
FYI This feature was requested in https:/
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/commissio
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-
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/commissio
> 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.
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.
Gavin Panella (allenap) wrote : | # |
Here's my rough suggestion for what to do now:
Change PowerDriverCheck to something like:
from provisioningser
Compresse
Choice,
)
class PowerDriverChec
"""Check power driver on cluster for missing packages.
:since: 1.9
"""
arguments = [
]
response = [
})),
])),
]
errors = {
}
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 PowerDriverMiss
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.
Gavin Panella (allenap) wrote : | # |
One more comment.
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.
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.
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.
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 :)
Gavin Panella (allenap) wrote : | # |
A few more comments but they're all minor. Nice stuff!
Preview Diff
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) |
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.