Merge ~newell-jensen/maas:redfish-power-driver into maas:master
- Git
- lp:~newell-jensen/maas
- redfish-power-driver
- Merge into master
Status: | Merged | ||||
---|---|---|---|---|---|
Approved by: | Mike Pontillo | ||||
Approved revision: | a7ee1527a9fe9c0e900d66eb39e42a5fb52d7122 | ||||
Merge reported by: | MAAS Lander | ||||
Merged at revision: | not available | ||||
Proposed branch: | ~newell-jensen/maas:redfish-power-driver | ||||
Merge into: | maas:master | ||||
Diff against target: |
1118 lines (+701/-225) 5 files modified
src/provisioningserver/drivers/pod/rsd.py (+31/-59) src/provisioningserver/drivers/pod/tests/test_rsd.py (+12/-166) src/provisioningserver/drivers/power/redfish.py (+217/-0) src/provisioningserver/drivers/power/registry.py (+2/-0) src/provisioningserver/drivers/power/tests/test_redfish.py (+439/-0) |
||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Mike Pontillo (community) | Approve | ||
MAAS Lander | Approve | ||
Review via email: mp+359967@code.launchpad.net |
Commit message
Refactor out redfish common code from the Intel RSD Pod driver to RedfishPowerDriver and have RSDPodDriver inherit from this class.
Description of the change
This branch factors out the redfish logic from the Intel RSD Pod driver and put this in its own RedfishPowerDriver class that RSDPodDriver now inherits from. This branch does not add SSL verification support, which will be done in a follow up branch.
Mike Pontillo (mpontillo) wrote : | # |
This change looks straightforward despite its large size. Let's hold off approving this branch until 2.5.1 opens, as Andres reminded us this morning.
I have a minor comment regarding a comment below.
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b redfish-
STATUS: FAILED
LOG: http://
COMMIT: 28214014ad692c6
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b redfish-
STATUS: SUCCESS
COMMIT: a7ee1527a9fe9c0
Mike Pontillo (mpontillo) wrote : | # |
Looks good to me; nice job!
I understand we will address https certificate verification issues in a subsequent commit; I think that's fine.
MAAS Lander (maas-lander) wrote : | # |
LANDING
-b redfish-
STATUS: FAILED BUILD
LOG: http://
Preview Diff
1 | diff --git a/src/provisioningserver/drivers/pod/rsd.py b/src/provisioningserver/drivers/pod/rsd.py |
2 | index 97c1764..8230a12 100644 |
3 | --- a/src/provisioningserver/drivers/pod/rsd.py |
4 | +++ b/src/provisioningserver/drivers/pod/rsd.py |
5 | @@ -1,4 +1,4 @@ |
6 | -# Copyright 2017 Canonical Ltd. This software is licensed under the |
7 | +# Copyright 2017-2018 Canonical Ltd. This software is licensed under the |
8 | # GNU Affero General Public License version 3 (see the file LICENSE). |
9 | |
10 | """Rack Scale Design pod driver.""" |
11 | @@ -7,7 +7,6 @@ __all__ = [ |
12 | 'RSDPodDriver', |
13 | ] |
14 | |
15 | -from base64 import b64encode |
16 | from http import HTTPStatus |
17 | from io import BytesIO |
18 | import json |
19 | @@ -27,9 +26,13 @@ from provisioningserver.drivers.pod import ( |
20 | DiscoveredPod, |
21 | DiscoveredPodHints, |
22 | PodActionError, |
23 | - PodDriver, |
24 | + PodDriverBase, |
25 | PodFatalError, |
26 | ) |
27 | +from provisioningserver.drivers.power.redfish import ( |
28 | + RedfishPowerDriverBase, |
29 | + WebClientContextFactory, |
30 | +) |
31 | from provisioningserver.logger import get_maas_logger |
32 | from provisioningserver.rpc.exceptions import PodInvalidResources |
33 | from provisioningserver.utils.twisted import ( |
34 | @@ -37,23 +40,19 @@ from provisioningserver.utils.twisted import ( |
35 | pause, |
36 | ) |
37 | from twisted.internet import reactor |
38 | -from twisted.internet._sslverify import ( |
39 | - ClientTLSOptions, |
40 | - OpenSSLCertificateOptions, |
41 | -) |
42 | from twisted.internet.defer import inlineCallbacks |
43 | from twisted.web.client import ( |
44 | Agent, |
45 | - BrowserLikePolicyForHTTPS, |
46 | FileBodyProducer, |
47 | PartialDownloadError, |
48 | readBody, |
49 | ) |
50 | -from twisted.web.http_headers import Headers |
51 | |
52 | |
53 | maaslog = get_maas_logger("drivers.pod.rsd") |
54 | |
55 | +RSD_POWER_CONTROL_ENDPOINT = b"redfish/v1/Nodes/%s/Actions/ComposedNode.Reset" |
56 | + |
57 | # RSD stores the architecture with a different |
58 | # label then MAAS. This maps RSD architecture to |
59 | # MAAS architecture. |
60 | @@ -76,19 +75,12 @@ RSD_NODE_POWER_STATE = { |
61 | } |
62 | |
63 | |
64 | -class WebClientContextFactory(BrowserLikePolicyForHTTPS): |
65 | - |
66 | - def creatorForNetloc(self, hostname, port): |
67 | - opts = ClientTLSOptions( |
68 | - hostname.decode("ascii"), |
69 | - OpenSSLCertificateOptions(verify=False).getContext()) |
70 | - # This forces Twisted to not validate the hostname of the certificate. |
71 | - opts._ctx.set_info_callback(lambda *args: None) |
72 | - return opts |
73 | - |
74 | +class RSDPodDriver(RedfishPowerDriverBase, PodDriverBase): |
75 | |
76 | -class RSDPodDriver(PodDriver): |
77 | + chassis = True # Pods are always a chassis |
78 | |
79 | + # RSDPodDriver inherits from RedfishPowerDriver. |
80 | + # Power parameters will need to be changed to reflect this. |
81 | name = 'rsd' |
82 | description = "Rack Scale Design" |
83 | settings = [ |
84 | @@ -108,26 +100,6 @@ class RSDPodDriver(PodDriver): |
85 | # no required packages |
86 | return [] |
87 | |
88 | - def get_url(self, context): |
89 | - """Return url for the pod.""" |
90 | - url = context.get('power_address') |
91 | - if "https" not in url and "http" not in url: |
92 | - # Prepend https |
93 | - url = join("https://", url) |
94 | - return url.encode('utf-8') |
95 | - |
96 | - def make_auth_headers(self, power_user, power_pass, **kwargs): |
97 | - """Return authentication headers.""" |
98 | - creds = "%s:%s" % (power_user, power_pass) |
99 | - authorization = b64encode(creds.encode('utf-8')) |
100 | - return Headers( |
101 | - { |
102 | - b"User-Agent": [b"MAAS"], |
103 | - b"Authorization": [b"Basic " + authorization], |
104 | - b"Content-Type": [b"application/json; charset=utf-8"], |
105 | - } |
106 | - ) |
107 | - |
108 | @asynchronous |
109 | def redfish_request(self, method, uri, headers=None, bodyProducer=None): |
110 | """Send the redfish request and return the response.""" |
111 | @@ -140,7 +112,7 @@ class RSDPodDriver(PodDriver): |
112 | |
113 | def eb_catch_partial(failure): |
114 | # Twisted is raising PartialDownloadError because the responses |
115 | - # do not contains a Content-Length header. Since every response |
116 | + # do not contain a Content-Length header. Since every response |
117 | # holds the whole body we just take the result. |
118 | failure.trap(PartialDownloadError) |
119 | if int(failure.value.status) == HTTPStatus.OK: |
120 | @@ -150,7 +122,7 @@ class RSDPodDriver(PodDriver): |
121 | |
122 | def cb_json_decode(data): |
123 | data = data.decode('utf-8') |
124 | - # Only decode non-empty responses. |
125 | + # Only decode non-empty response bodies. |
126 | if data: |
127 | response = json.loads(data) |
128 | if "error" in response: |
129 | @@ -1084,22 +1056,6 @@ class RSDPodDriver(PodDriver): |
130 | return discovered_pod.hints |
131 | |
132 | @inlineCallbacks |
133 | - def set_pxe_boot(self, url, node_id, headers): |
134 | - """Set the composed machine with node_id to PXE boot.""" |
135 | - endpoint = b"redfish/v1/Nodes/%s" % node_id |
136 | - payload = FileBodyProducer( |
137 | - BytesIO( |
138 | - json.dumps( |
139 | - { |
140 | - 'Boot': { |
141 | - 'BootSourceOverrideEnabled': "Once", |
142 | - 'BootSourceOverrideTarget': "Pxe" |
143 | - } |
144 | - }).encode('utf-8'))) |
145 | - yield self.redfish_request( |
146 | - b"PATCH", join(url, endpoint), headers, payload) |
147 | - |
148 | - @inlineCallbacks |
149 | def get_composed_node_state(self, url, node_id, headers): |
150 | """Return the `ComposedNodeState` of the composed machine.""" |
151 | endpoint = b"redfish/v1/Nodes/%s" % node_id |
152 | @@ -1149,8 +1105,24 @@ class RSDPodDriver(PodDriver): |
153 | " of Failed." % node_id) |
154 | |
155 | @inlineCallbacks |
156 | + def set_pxe_boot(self, url, node_id, headers): |
157 | + """Set the composed machine with node_id to PXE boot.""" |
158 | + endpoint = b"redfish/v1/Nodes/%s" % node_id |
159 | + payload = FileBodyProducer( |
160 | + BytesIO( |
161 | + json.dumps( |
162 | + { |
163 | + 'Boot': { |
164 | + 'BootSourceOverrideEnabled': "Once", |
165 | + 'BootSourceOverrideTarget': "Pxe" |
166 | + } |
167 | + }).encode('utf-8'))) |
168 | + yield self.redfish_request( |
169 | + b"PATCH", join(url, endpoint), headers, payload) |
170 | + |
171 | + @inlineCallbacks |
172 | def power(self, power_change, url, node_id, headers): |
173 | - endpoint = b"redfish/v1/Nodes/%s/Actions/ComposedNode.Reset" % node_id |
174 | + endpoint = RSD_POWER_CONTROL_ENDPOINT % node_id |
175 | payload = FileBodyProducer( |
176 | BytesIO( |
177 | json.dumps( |
178 | diff --git a/src/provisioningserver/drivers/pod/tests/test_rsd.py b/src/provisioningserver/drivers/pod/tests/test_rsd.py |
179 | index b3464c5..b4cc83c 100644 |
180 | --- a/src/provisioningserver/drivers/pod/tests/test_rsd.py |
181 | +++ b/src/provisioningserver/drivers/pod/tests/test_rsd.py |
182 | @@ -1,21 +1,17 @@ |
183 | -# Copyright 2017 Canonical Ltd. This software is licensed under the |
184 | +# Copyright 2017-2018 Canonical Ltd. This software is licensed under the |
185 | # GNU Affero General Public License version 3 (see the file LICENSE). |
186 | |
187 | """Tests for `provisioningserver.drivers.pod.rsd`.""" |
188 | |
189 | __all__ = [] |
190 | |
191 | -from base64 import b64encode |
192 | from copy import deepcopy |
193 | from http import HTTPStatus |
194 | from io import BytesIO |
195 | import json |
196 | from os.path import join |
197 | import random |
198 | -from unittest.mock import ( |
199 | - call, |
200 | - Mock, |
201 | -) |
202 | +from unittest.mock import call |
203 | |
204 | from maastesting.factory import factory |
205 | from maastesting.matchers import ( |
206 | @@ -45,7 +41,6 @@ from provisioningserver.drivers.pod.rsd import ( |
207 | RSD_NODE_POWER_STATE, |
208 | RSD_SYSTEM_POWER_STATE, |
209 | RSDPodDriver, |
210 | - WebClientContextFactory, |
211 | ) |
212 | import provisioningserver.drivers.pod.rsd as rsd_module |
213 | from provisioningserver.rpc.exceptions import PodInvalidResources |
214 | @@ -57,12 +52,7 @@ from testtools.matchers import ( |
215 | MatchesListwise, |
216 | MatchesStructure, |
217 | ) |
218 | -from twisted.internet._sslverify import ClientTLSOptions |
219 | -from twisted.internet.defer import ( |
220 | - fail, |
221 | - inlineCallbacks, |
222 | - succeed, |
223 | -) |
224 | +from twisted.internet.defer import inlineCallbacks |
225 | from twisted.web.client import ( |
226 | FileBodyProducer, |
227 | PartialDownloadError, |
228 | @@ -790,16 +780,6 @@ def make_discovered_pod( |
229 | return discovered_pod |
230 | |
231 | |
232 | -class TestWebClientContextFactory(MAASTestCase): |
233 | - |
234 | - def test_creatorForNetloc_returns_tls_options(self): |
235 | - hostname = factory.make_name('hostname').encode('utf-8') |
236 | - port = random.randint(1000, 2000) |
237 | - contextFactory = WebClientContextFactory() |
238 | - opts = contextFactory.creatorForNetloc(hostname, port) |
239 | - self.assertIsInstance(opts, ClientTLSOptions) |
240 | - |
241 | - |
242 | class TestRSDPodDriver(MAASTestCase): |
243 | |
244 | run_tests_with = MAASTwistedRunTest.make_factory(timeout=5) |
245 | @@ -810,116 +790,6 @@ class TestRSDPodDriver(MAASTestCase): |
246 | missing = driver.detect_missing_packages() |
247 | self.assertItemsEqual([], missing) |
248 | |
249 | - def test_get_url_with_ip(self): |
250 | - driver = RSDPodDriver() |
251 | - context = make_context() |
252 | - ip = context.get('power_address').encode('utf-8') |
253 | - expected_url = b"https://%s" % ip |
254 | - url = driver.get_url(context) |
255 | - self.assertEqual(expected_url, url) |
256 | - |
257 | - def test_get_url_with_https(self): |
258 | - driver = RSDPodDriver() |
259 | - context = make_context() |
260 | - context['power_address'] = join( |
261 | - "https://", context['power_address']) |
262 | - expected_url = context.get('power_address').encode('utf-8') |
263 | - url = driver.get_url(context) |
264 | - self.assertEqual(expected_url, url) |
265 | - |
266 | - def test_get_url_with_http(self): |
267 | - driver = RSDPodDriver() |
268 | - context = make_context() |
269 | - context['power_address'] = join( |
270 | - "http://", context['power_address']) |
271 | - expected_url = context.get('power_address').encode('utf-8') |
272 | - url = driver.get_url(context) |
273 | - self.assertEqual(expected_url, url) |
274 | - |
275 | - def test__make_auth_headers(self): |
276 | - power_user = factory.make_name('power_user') |
277 | - power_pass = factory.make_name('power_pass') |
278 | - creds = "%s:%s" % (power_user, power_pass) |
279 | - authorization = b64encode(creds.encode('utf-8')) |
280 | - attributes = { |
281 | - b"User-Agent": [b"MAAS"], |
282 | - b"Authorization": [b"Basic " + authorization], |
283 | - b"Content-Type": [b"application/json; charset=utf-8"], |
284 | - } |
285 | - driver = RSDPodDriver() |
286 | - headers = driver.make_auth_headers(power_user, power_pass) |
287 | - self.assertEquals(headers, Headers(attributes)) |
288 | - |
289 | - @inlineCallbacks |
290 | - def test_redfish_request_renders_response(self): |
291 | - driver = RSDPodDriver() |
292 | - context = make_context() |
293 | - url = driver.get_url(context) |
294 | - uri = join(url, b"redfish/v1/Systems") |
295 | - headers = driver.make_auth_headers(**context) |
296 | - mock_agent = self.patch(rsd_module, 'Agent') |
297 | - mock_agent.return_value.request = Mock() |
298 | - expected_headers = Mock() |
299 | - expected_headers.headers = "Testing Headers" |
300 | - mock_agent.return_value.request.return_value = succeed( |
301 | - expected_headers) |
302 | - mock_readBody = self.patch(rsd_module, 'readBody') |
303 | - mock_readBody.return_value = succeed( |
304 | - json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8')) |
305 | - expected_response = SAMPLE_JSON_SYSTEMS |
306 | - |
307 | - response, headers = yield driver.redfish_request(b"GET", uri, headers) |
308 | - self.assertEquals(expected_response, response) |
309 | - self.assertEquals(expected_headers.headers, headers) |
310 | - |
311 | - @inlineCallbacks |
312 | - def test_redfish_request_continues_partial_download_error(self): |
313 | - driver = RSDPodDriver() |
314 | - context = make_context() |
315 | - url = driver.get_url(context) |
316 | - uri = join(url, b"redfish/v1/Systems") |
317 | - headers = driver.make_auth_headers(**context) |
318 | - mock_agent = self.patch(rsd_module, 'Agent') |
319 | - mock_agent.return_value.request = Mock() |
320 | - expected_headers = Mock() |
321 | - expected_headers.headers = "Testing Headers" |
322 | - mock_agent.return_value.request.return_value = succeed( |
323 | - expected_headers) |
324 | - mock_readBody = self.patch(rsd_module, 'readBody') |
325 | - error = PartialDownloadError( |
326 | - response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'), |
327 | - code=HTTPStatus.OK) |
328 | - mock_readBody.return_value = fail(error) |
329 | - expected_response = SAMPLE_JSON_SYSTEMS |
330 | - |
331 | - response, headers = yield driver.redfish_request(b"GET", uri, headers) |
332 | - self.assertEquals(expected_response, response) |
333 | - self.assertEquals(expected_headers.headers, headers) |
334 | - |
335 | - @inlineCallbacks |
336 | - def test_redfish_request_raises_failures(self): |
337 | - driver = RSDPodDriver() |
338 | - context = make_context() |
339 | - url = driver.get_url(context) |
340 | - uri = join(url, b"redfish/v1/Systems") |
341 | - headers = driver.make_auth_headers(**context) |
342 | - mock_agent = self.patch(rsd_module, 'Agent') |
343 | - mock_agent.return_value.request = Mock() |
344 | - expected_headers = Mock() |
345 | - expected_headers.headers = "Testing Headers" |
346 | - mock_agent.return_value.request.return_value = succeed( |
347 | - expected_headers) |
348 | - mock_readBody = self.patch(rsd_module, 'readBody') |
349 | - error = PartialDownloadError( |
350 | - response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'), |
351 | - code=HTTPStatus.NOT_FOUND) |
352 | - mock_readBody.return_value = fail(error) |
353 | - |
354 | - with ExpectedException(PartialDownloadError): |
355 | - yield driver.redfish_request(b"GET", uri, headers) |
356 | - self.assertThat(mock_readBody, MockCalledOnceWith( |
357 | - expected_headers)) |
358 | - |
359 | @inlineCallbacks |
360 | def test__list_resources(self): |
361 | driver = RSDPodDriver() |
362 | @@ -2088,32 +1958,6 @@ class TestRSDPodDriver(MAASTestCase): |
363 | discovered_pod)) |
364 | |
365 | @inlineCallbacks |
366 | - def test__set_pxe_boot(self): |
367 | - driver = RSDPodDriver() |
368 | - context = make_context() |
369 | - url = driver.get_url(context) |
370 | - node_id = context.get('node_id').encode('utf-8') |
371 | - headers = driver.make_auth_headers(**context) |
372 | - mock_file_body_producer = self.patch( |
373 | - rsd_module, 'FileBodyProducer') |
374 | - payload = FileBodyProducer( |
375 | - BytesIO( |
376 | - json.dumps( |
377 | - { |
378 | - 'Boot': { |
379 | - 'BootSourceOverrideEnabled': "Once", |
380 | - 'BootSourceOverrideTarget': "Pxe" |
381 | - } |
382 | - }).encode('utf-8'))) |
383 | - mock_file_body_producer.return_value = payload |
384 | - mock_redfish_request = self.patch(driver, 'redfish_request') |
385 | - |
386 | - yield driver.set_pxe_boot(url, node_id, headers) |
387 | - self.assertThat(mock_redfish_request, MockCalledOnceWith( |
388 | - b"PATCH", join(url, b"redfish/v1/Nodes/%s" % node_id), |
389 | - headers, payload)) |
390 | - |
391 | - @inlineCallbacks |
392 | def test__get_composed_node_state(self): |
393 | driver = RSDPodDriver() |
394 | context = make_context() |
395 | @@ -2220,10 +2064,9 @@ class TestRSDPodDriver(MAASTestCase): |
396 | url, node_id, headers)) |
397 | |
398 | @inlineCallbacks |
399 | - def test_power_issues_power_reset(self): |
400 | + def test__set_pxe_boot(self): |
401 | driver = RSDPodDriver() |
402 | context = make_context() |
403 | - power_change = factory.make_name('power_change') |
404 | url = driver.get_url(context) |
405 | node_id = context.get('node_id').encode('utf-8') |
406 | headers = driver.make_auth_headers(**context) |
407 | @@ -2233,15 +2076,18 @@ class TestRSDPodDriver(MAASTestCase): |
408 | BytesIO( |
409 | json.dumps( |
410 | { |
411 | - 'ResetType': "%s" % power_change |
412 | + 'Boot': { |
413 | + 'BootSourceOverrideEnabled': "Once", |
414 | + 'BootSourceOverrideTarget': "Pxe" |
415 | + } |
416 | }).encode('utf-8'))) |
417 | mock_file_body_producer.return_value = payload |
418 | mock_redfish_request = self.patch(driver, 'redfish_request') |
419 | - expected_uri = join( |
420 | - url, b"redfish/v1/Nodes/%s/Actions/ComposedNode.Reset" % node_id) |
421 | - yield driver.power(power_change, url, node_id, headers) |
422 | + |
423 | + yield driver.set_pxe_boot(url, node_id, headers) |
424 | self.assertThat(mock_redfish_request, MockCalledOnceWith( |
425 | - b"POST", expected_uri, headers, payload)) |
426 | + b"PATCH", join(url, b"redfish/v1/Nodes/%s" % node_id), |
427 | + headers, payload)) |
428 | |
429 | @inlineCallbacks |
430 | def test__power_on(self): |
431 | diff --git a/src/provisioningserver/drivers/power/redfish.py b/src/provisioningserver/drivers/power/redfish.py |
432 | new file mode 100644 |
433 | index 0000000..5b47e21 |
434 | --- /dev/null |
435 | +++ b/src/provisioningserver/drivers/power/redfish.py |
436 | @@ -0,0 +1,217 @@ |
437 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
438 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
439 | + |
440 | +"""Redfish Power Driver.""" |
441 | + |
442 | +__all__ = [ |
443 | + 'RedfishPowerDriver', |
444 | + ] |
445 | + |
446 | +from base64 import b64encode |
447 | +from http import HTTPStatus |
448 | +from io import BytesIO |
449 | +import json |
450 | +from os.path import join |
451 | + |
452 | +from provisioningserver.drivers import ( |
453 | + make_ip_extractor, |
454 | + make_setting_field, |
455 | + SETTING_SCOPE, |
456 | +) |
457 | +from provisioningserver.drivers.power import ( |
458 | + PowerActionError, |
459 | + PowerDriver, |
460 | +) |
461 | +from provisioningserver.utils.twisted import asynchronous |
462 | +from twisted.internet import reactor |
463 | +from twisted.internet._sslverify import ( |
464 | + ClientTLSOptions, |
465 | + OpenSSLCertificateOptions, |
466 | +) |
467 | +from twisted.internet.defer import inlineCallbacks |
468 | +from twisted.web.client import ( |
469 | + Agent, |
470 | + BrowserLikePolicyForHTTPS, |
471 | + FileBodyProducer, |
472 | + PartialDownloadError, |
473 | + readBody, |
474 | +) |
475 | +from twisted.web.http_headers import Headers |
476 | + |
477 | + |
478 | +REDFISH_POWER_CONTROL_ENDPOINT = ( |
479 | + b"redfish/v1/Systems/%s/Actions/ComputerSystem.Reset/") |
480 | + |
481 | +REDFISH_SYSTEMS_ENDPOINT = b"redfish/v1/Systems/%s/" |
482 | + |
483 | + |
484 | +class WebClientContextFactory(BrowserLikePolicyForHTTPS): |
485 | + |
486 | + def creatorForNetloc(self, hostname, port): |
487 | + opts = ClientTLSOptions( |
488 | + hostname.decode("ascii"), |
489 | + OpenSSLCertificateOptions(verify=False).getContext()) |
490 | + # This forces Twisted to not validate the hostname of the certificate. |
491 | + opts._ctx.set_info_callback(lambda *args: None) |
492 | + return opts |
493 | + |
494 | + |
495 | +class RedfishPowerDriverBase(PowerDriver): |
496 | + |
497 | + def get_url(self, context): |
498 | + """Return url for the pod.""" |
499 | + url = context.get('power_address') |
500 | + if "https" not in url and "http" not in url: |
501 | + # Prepend https |
502 | + url = join("https://", url) |
503 | + return url.encode('utf-8') |
504 | + |
505 | + def make_auth_headers(self, power_user, power_pass, **kwargs): |
506 | + """Return authentication headers.""" |
507 | + creds = "%s:%s" % (power_user, power_pass) |
508 | + authorization = b64encode(creds.encode('utf-8')) |
509 | + return Headers( |
510 | + { |
511 | + b"User-Agent": [b"MAAS"], |
512 | + b"Authorization": [b"Basic " + authorization], |
513 | + b"Content-Type": [b"application/json; charset=utf-8"], |
514 | + } |
515 | + ) |
516 | + |
517 | + def process_redfish_context(self, context): |
518 | + """Process Redfish power driver context.""" |
519 | + url = self.get_url(context) |
520 | + node_id = context.get('node_id').encode('utf-8') |
521 | + headers = self.make_auth_headers(**context) |
522 | + return url, node_id, headers |
523 | + |
524 | + @asynchronous |
525 | + def redfish_request(self, method, uri, headers=None, bodyProducer=None): |
526 | + """Send the redfish request and return the response.""" |
527 | + agent = Agent(reactor, contextFactory=WebClientContextFactory()) |
528 | + d = agent.request( |
529 | + method, uri, headers=headers, bodyProducer=bodyProducer) |
530 | + |
531 | + def render_response(response): |
532 | + """Render the HTTPS response received.""" |
533 | + |
534 | + def eb_catch_partial(failure): |
535 | + # Twisted is raising PartialDownloadError because the responses |
536 | + # do not contain a Content-Length header. Since every response |
537 | + # holds the whole body we just take the result. |
538 | + failure.trap(PartialDownloadError) |
539 | + if int(failure.value.status) == HTTPStatus.OK: |
540 | + return failure.value.response |
541 | + else: |
542 | + return failure |
543 | + |
544 | + def cb_json_decode(data): |
545 | + data = data.decode('utf-8') |
546 | + # Only decode non-empty response bodies. |
547 | + if data: |
548 | + return json.loads(data) |
549 | + |
550 | + def cb_attach_headers(data, headers): |
551 | + return data, headers |
552 | + |
553 | + # Error out if the response has a status code of 400 or above. |
554 | + if response.code >= int(HTTPStatus.BAD_REQUEST): |
555 | + raise PowerActionError( |
556 | + "Redfish request failed with response status code:" |
557 | + " %s." % response.code) |
558 | + |
559 | + d = readBody(response) |
560 | + d.addErrback(eb_catch_partial) |
561 | + d.addCallback(cb_json_decode) |
562 | + d.addCallback(cb_attach_headers, headers=response.headers) |
563 | + return d |
564 | + |
565 | + d.addCallback(render_response) |
566 | + return d |
567 | + |
568 | + |
569 | +class RedfishPowerDriver(RedfishPowerDriverBase): |
570 | + |
571 | + chassis = True # Redfish API endpoints can be probed and enlisted. |
572 | + |
573 | + name = 'redfish' |
574 | + description = "Redfish" |
575 | + settings = [ |
576 | + make_setting_field( |
577 | + 'power_address', "Redfish address", required=True), |
578 | + make_setting_field('power_user', "Redfish user", required=True), |
579 | + make_setting_field( |
580 | + 'power_pass', "Redfish password", |
581 | + field_type='password', required=True), |
582 | + make_setting_field( |
583 | + 'node_id', "Node ID", |
584 | + scope=SETTING_SCOPE.NODE, required=True), |
585 | + ] |
586 | + ip_extractor = make_ip_extractor('power_address') |
587 | + |
588 | + def detect_missing_packages(self): |
589 | + # no required packages |
590 | + return [] |
591 | + |
592 | + @inlineCallbacks |
593 | + def set_pxe_boot(self, url, node_id, headers): |
594 | + """Set the machine with node_id to PXE boot.""" |
595 | + endpoint = REDFISH_SYSTEMS_ENDPOINT % node_id |
596 | + payload = FileBodyProducer( |
597 | + BytesIO( |
598 | + json.dumps( |
599 | + { |
600 | + 'Boot': { |
601 | + 'BootSourceOverrideEnabled': "Once", |
602 | + 'BootSourceOverrideTarget': "Pxe" |
603 | + } |
604 | + }).encode('utf-8'))) |
605 | + yield self.redfish_request( |
606 | + b"PATCH", join(url, endpoint), headers, payload) |
607 | + |
608 | + @inlineCallbacks |
609 | + def power(self, power_change, url, node_id, headers): |
610 | + """Issue `power` command.""" |
611 | + endpoint = REDFISH_POWER_CONTROL_ENDPOINT % node_id |
612 | + payload = FileBodyProducer( |
613 | + BytesIO( |
614 | + json.dumps( |
615 | + { |
616 | + 'Action': "Reset", |
617 | + 'ResetType': "%s" % power_change |
618 | + }).encode('utf-8'))) |
619 | + yield self.redfish_request( |
620 | + b"POST", join(url, endpoint), headers, payload) |
621 | + |
622 | + @asynchronous |
623 | + @inlineCallbacks |
624 | + def power_on(self, system_id, context): |
625 | + """Power on machine.""" |
626 | + url, node_id, headers = self.process_redfish_context(context) |
627 | + power_state = yield self.power_query(system_id, context) |
628 | + # Power off the machine if currently on. |
629 | + if power_state == 'on': |
630 | + yield self.power("ForceOff", url, node_id, headers) |
631 | + # Set to PXE boot. |
632 | + yield self.set_pxe_boot(url, node_id, headers) |
633 | + # Power on the machine. |
634 | + yield self.power("On", url, node_id, headers) |
635 | + |
636 | + @asynchronous |
637 | + @inlineCallbacks |
638 | + def power_off(self, system_id, context): |
639 | + """Power off machine.""" |
640 | + url, node_id, headers = self.process_redfish_context(context) |
641 | + # Set to PXE boot. |
642 | + yield self.set_pxe_boot(url, node_id, headers) |
643 | + # Power off the machine. |
644 | + yield self.power("ForceOff", url, node_id, headers) |
645 | + |
646 | + @asynchronous |
647 | + @inlineCallbacks |
648 | + def power_query(self, system_id, context): |
649 | + """Power query machine.""" |
650 | + url, node_id, headers = self.process_redfish_context(context) |
651 | + uri = join(url, REDFISH_SYSTEMS_ENDPOINT % node_id) |
652 | + node_data, _ = yield self.redfish_request(b"GET", uri, headers) |
653 | + return node_data.get('PowerState').lower() |
654 | diff --git a/src/provisioningserver/drivers/power/registry.py b/src/provisioningserver/drivers/power/registry.py |
655 | index 14a1136..78ddb92 100644 |
656 | --- a/src/provisioningserver/drivers/power/registry.py |
657 | +++ b/src/provisioningserver/drivers/power/registry.py |
658 | @@ -22,6 +22,7 @@ from provisioningserver.drivers.power.mscm import MSCMPowerDriver |
659 | from provisioningserver.drivers.power.msftocs import MicrosoftOCSPowerDriver |
660 | from provisioningserver.drivers.power.nova import NovaPowerDriver |
661 | from provisioningserver.drivers.power.recs import RECSPowerDriver |
662 | +from provisioningserver.drivers.power.redfish import RedfishPowerDriver |
663 | from provisioningserver.drivers.power.seamicro import SeaMicroPowerDriver |
664 | from provisioningserver.drivers.power.ucsm import UCSMPowerDriver |
665 | from provisioningserver.drivers.power.virsh import VirshPowerDriver |
666 | @@ -61,6 +62,7 @@ power_drivers = [ |
667 | MicrosoftOCSPowerDriver(), |
668 | NovaPowerDriver(), |
669 | RECSPowerDriver(), |
670 | + RedfishPowerDriver(), |
671 | SeaMicroPowerDriver(), |
672 | UCSMPowerDriver(), |
673 | VirshPowerDriver(), |
674 | diff --git a/src/provisioningserver/drivers/power/tests/test_redfish.py b/src/provisioningserver/drivers/power/tests/test_redfish.py |
675 | new file mode 100644 |
676 | index 0000000..f6db6e3 |
677 | --- /dev/null |
678 | +++ b/src/provisioningserver/drivers/power/tests/test_redfish.py |
679 | @@ -0,0 +1,439 @@ |
680 | +# Copyright 2018 Canonical Ltd. This software is licensed under the |
681 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
682 | + |
683 | +"""Tests for `provisioningserver.drivers.power.redfish`.""" |
684 | + |
685 | +__all__ = [] |
686 | + |
687 | + |
688 | +from base64 import b64encode |
689 | +from copy import deepcopy |
690 | +from http import HTTPStatus |
691 | +from io import BytesIO |
692 | +import json |
693 | +from os.path import join |
694 | +import random |
695 | +from unittest.mock import ( |
696 | + call, |
697 | + Mock, |
698 | +) |
699 | + |
700 | +from maastesting.factory import factory |
701 | +from maastesting.matchers import ( |
702 | + MockCalledOnceWith, |
703 | + MockCallsMatch, |
704 | + MockNotCalled, |
705 | +) |
706 | +from maastesting.testcase import ( |
707 | + MAASTestCase, |
708 | + MAASTwistedRunTest, |
709 | +) |
710 | +from provisioningserver.drivers.power import PowerActionError |
711 | +from provisioningserver.drivers.power.redfish import ( |
712 | + REDFISH_POWER_CONTROL_ENDPOINT, |
713 | + RedfishPowerDriver, |
714 | + WebClientContextFactory, |
715 | +) |
716 | +import provisioningserver.drivers.power.redfish as redfish_module |
717 | +from testtools import ExpectedException |
718 | +from twisted.internet._sslverify import ClientTLSOptions |
719 | +from twisted.internet.defer import ( |
720 | + fail, |
721 | + inlineCallbacks, |
722 | + succeed, |
723 | +) |
724 | +from twisted.web.client import ( |
725 | + FileBodyProducer, |
726 | + PartialDownloadError, |
727 | +) |
728 | +from twisted.web.http_headers import Headers |
729 | + |
730 | + |
731 | +SAMPLE_JSON_SYSTEMS = { |
732 | + "@odata.context": "/redfish/v1/$metadata#Systems", |
733 | + "@odata.count": 1, |
734 | + "@odata.id": "/redfish/v1/Systems", |
735 | + "@odata.type": "#ComputerSystem.1.0.0.ComputerSystemCollection", |
736 | + "Description": "Collection of Computer Systems", |
737 | + "Members": [ |
738 | + { |
739 | + "@odata.id": "/redfish/v1/Systems/1" |
740 | + } |
741 | + ], |
742 | + "Name": "Computer System Collection" |
743 | +} |
744 | + |
745 | +SAMPLE_JSON_SYSTEM = { |
746 | + "@odata.context": "/redfish/v1/$metadata#Systems/Members/$entity", |
747 | + "@odata.id": "/redfish/v1/Systems/1", |
748 | + "@odata.type": "#ComputerSystem.1.0.0.ComputerSystem", |
749 | + "Actions": { |
750 | + "#ComputerSystem.Reset": { |
751 | + "ResetType@Redfish.AllowableValues": [ |
752 | + "On", |
753 | + "ForceOff", |
754 | + "GracefulRestart", |
755 | + "PushPowerButton", |
756 | + "Nmi" |
757 | + ], |
758 | + "target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" |
759 | + } |
760 | + }, |
761 | + "AssetTag": "", |
762 | + "BiosVersion": "2.1.7", |
763 | + "Boot": { |
764 | + "BootSourceOverrideEnabled": "Once", |
765 | + "BootSourceOverrideTarget": "None", |
766 | + "BootSourceOverrideTarget@Redfish.AllowableValues": [ |
767 | + "None", |
768 | + "Pxe", |
769 | + "Floppy", |
770 | + "Cd", |
771 | + "Hdd", |
772 | + "BiosSetup", |
773 | + "Utilities", |
774 | + "UefiTarget" |
775 | + ], |
776 | + "UefiTargetBootSourceOverride": "" |
777 | + }, |
778 | + "Description": "Computer System which represents a machine.", |
779 | + "EthernetInterfaces": { |
780 | + "@odata.id": "/redfish/v1/Systems/1/EthernetInterfaces" |
781 | + }, |
782 | + "HostName": "WORTHY-BOAR", |
783 | + "Id": "1", |
784 | + "IndicatorLED": "Off", |
785 | + "Links": { |
786 | + "Chassis": [ |
787 | + { |
788 | + "@odata.id": "/redfish/v1/Chassis/1" |
789 | + } |
790 | + ], |
791 | + "ManagedBy": [ |
792 | + { |
793 | + "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1" |
794 | + } |
795 | + ], |
796 | + "PoweredBy": [ |
797 | + { |
798 | + "@odata.id": "/redfish/v1/Chassis/1/Power/PowerSupplies/..." |
799 | + }, |
800 | + { |
801 | + "@odata.id": "/redfish/v1/Chassis/1/Power/PowerSupplies/..." |
802 | + } |
803 | + ] |
804 | + }, |
805 | + "Manufacturer": "Dell Inc.", |
806 | + "MemorySummary": { |
807 | + "Status": { |
808 | + "Health": "OK", |
809 | + "HealthRollUp": "OK", |
810 | + "State": "Enabled" |
811 | + }, |
812 | + "TotalSystemMemoryGiB": 64 |
813 | + }, |
814 | + "Model": "PowerEdge R630", |
815 | + "Name": "System", |
816 | + "PartNumber": "02C2CPA01", |
817 | + "PowerState": "Off", |
818 | + "ProcessorSummary": { |
819 | + "Count": 2, |
820 | + "Model": "Intel(R) Xeon(R) CPU E5-2667 v4 @ 3.20GHz", |
821 | + "Status": { |
822 | + "Health": "Critical", |
823 | + "HealthRollUp": "Critical", |
824 | + "State": "Enabled" |
825 | + } |
826 | + }, |
827 | + "Processors": { |
828 | + "@odata.id": "/redfish/v1/Systems/1/Processors" |
829 | + }, |
830 | + "SKU": "7PW1RD2", |
831 | + "SerialNumber": "CN7475166I0364", |
832 | + "SimpleStorage": { |
833 | + "@odata.id": "/redfish/v1/Systems/1/Storage/Controllers" |
834 | + }, |
835 | + "Status": { |
836 | + "Health": "Critical", |
837 | + "HealthRollUp": "Critical", |
838 | + "State": "Offline" |
839 | + }, |
840 | + "SystemType": "Physical", |
841 | + "UUID": "4c4c4544-0050-5710-8031-b7c04f524432" |
842 | +} |
843 | + |
844 | + |
845 | +def make_context(): |
846 | + return { |
847 | + 'power_address': factory.make_ipv4_address(), |
848 | + 'power_user': factory.make_name('power_user'), |
849 | + 'power_pass': factory.make_name('power_pass'), |
850 | + 'node_id': factory.make_name('node_id'), |
851 | + } |
852 | + |
853 | + |
854 | +class TestWebClientContextFactory(MAASTestCase): |
855 | + |
856 | + def test_creatorForNetloc_returns_tls_options(self): |
857 | + hostname = factory.make_name('hostname').encode('utf-8') |
858 | + port = random.randint(1000, 2000) |
859 | + contextFactory = WebClientContextFactory() |
860 | + opts = contextFactory.creatorForNetloc(hostname, port) |
861 | + self.assertIsInstance(opts, ClientTLSOptions) |
862 | + |
863 | + |
864 | +class TestRedfishPowerDriver(MAASTestCase): |
865 | + |
866 | + run_tests_with = MAASTwistedRunTest.make_factory(timeout=5) |
867 | + |
868 | + def test_missing_packages(self): |
869 | + # there's nothing to check for, just confirm it returns [] |
870 | + driver = RedfishPowerDriver() |
871 | + missing = driver.detect_missing_packages() |
872 | + self.assertItemsEqual([], missing) |
873 | + |
874 | + def test_get_url_with_ip(self): |
875 | + driver = RedfishPowerDriver() |
876 | + context = make_context() |
877 | + ip = context.get('power_address').encode('utf-8') |
878 | + expected_url = b"https://%s" % ip |
879 | + url = driver.get_url(context) |
880 | + self.assertEqual(expected_url, url) |
881 | + |
882 | + def test_get_url_with_https(self): |
883 | + driver = RedfishPowerDriver() |
884 | + context = make_context() |
885 | + context['power_address'] = join( |
886 | + "https://", context['power_address']) |
887 | + expected_url = context.get('power_address').encode('utf-8') |
888 | + url = driver.get_url(context) |
889 | + self.assertEqual(expected_url, url) |
890 | + |
891 | + def test_get_url_with_http(self): |
892 | + driver = RedfishPowerDriver() |
893 | + context = make_context() |
894 | + context['power_address'] = join( |
895 | + "http://", context['power_address']) |
896 | + expected_url = context.get('power_address').encode('utf-8') |
897 | + url = driver.get_url(context) |
898 | + self.assertEqual(expected_url, url) |
899 | + |
900 | + def test__make_auth_headers(self): |
901 | + power_user = factory.make_name('power_user') |
902 | + power_pass = factory.make_name('power_pass') |
903 | + creds = "%s:%s" % (power_user, power_pass) |
904 | + authorization = b64encode(creds.encode('utf-8')) |
905 | + attributes = { |
906 | + b"User-Agent": [b"MAAS"], |
907 | + b"Authorization": [b"Basic " + authorization], |
908 | + b"Content-Type": [b"application/json; charset=utf-8"], |
909 | + } |
910 | + driver = RedfishPowerDriver() |
911 | + headers = driver.make_auth_headers(power_user, power_pass) |
912 | + self.assertEquals(headers, Headers(attributes)) |
913 | + |
914 | + @inlineCallbacks |
915 | + def test_redfish_request_renders_response(self): |
916 | + driver = RedfishPowerDriver() |
917 | + context = make_context() |
918 | + url = driver.get_url(context) |
919 | + uri = join(url, b"redfish/v1/Systems") |
920 | + headers = driver.make_auth_headers(**context) |
921 | + mock_agent = self.patch(redfish_module, 'Agent') |
922 | + mock_agent.return_value.request = Mock() |
923 | + expected_headers = Mock() |
924 | + expected_headers.code = HTTPStatus.OK |
925 | + expected_headers.headers = "Testing Headers" |
926 | + mock_agent.return_value.request.return_value = succeed( |
927 | + expected_headers) |
928 | + mock_readBody = self.patch(redfish_module, 'readBody') |
929 | + mock_readBody.return_value = succeed( |
930 | + json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8')) |
931 | + expected_response = SAMPLE_JSON_SYSTEMS |
932 | + |
933 | + response, headers = yield driver.redfish_request(b"GET", uri, headers) |
934 | + self.assertEquals(expected_response, response) |
935 | + self.assertEquals(expected_headers.headers, headers) |
936 | + |
937 | + @inlineCallbacks |
938 | + def test_redfish_request_continues_partial_download_error(self): |
939 | + driver = RedfishPowerDriver() |
940 | + context = make_context() |
941 | + url = driver.get_url(context) |
942 | + uri = join(url, b"redfish/v1/Systems") |
943 | + headers = driver.make_auth_headers(**context) |
944 | + mock_agent = self.patch(redfish_module, 'Agent') |
945 | + mock_agent.return_value.request = Mock() |
946 | + expected_headers = Mock() |
947 | + expected_headers.code = HTTPStatus.OK |
948 | + expected_headers.headers = "Testing Headers" |
949 | + mock_agent.return_value.request.return_value = succeed( |
950 | + expected_headers) |
951 | + mock_readBody = self.patch(redfish_module, 'readBody') |
952 | + error = PartialDownloadError( |
953 | + response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'), |
954 | + code=HTTPStatus.OK) |
955 | + mock_readBody.return_value = fail(error) |
956 | + expected_response = SAMPLE_JSON_SYSTEMS |
957 | + |
958 | + response, headers = yield driver.redfish_request(b"GET", uri, headers) |
959 | + self.assertEquals(expected_response, response) |
960 | + self.assertEquals(expected_headers.headers, headers) |
961 | + |
962 | + @inlineCallbacks |
963 | + def test_redfish_request_raises_failures(self): |
964 | + driver = RedfishPowerDriver() |
965 | + context = make_context() |
966 | + url = driver.get_url(context) |
967 | + uri = join(url, b"redfish/v1/Systems") |
968 | + headers = driver.make_auth_headers(**context) |
969 | + mock_agent = self.patch(redfish_module, 'Agent') |
970 | + mock_agent.return_value.request = Mock() |
971 | + expected_headers = Mock() |
972 | + expected_headers.code = HTTPStatus.OK |
973 | + expected_headers.headers = "Testing Headers" |
974 | + mock_agent.return_value.request.return_value = succeed( |
975 | + expected_headers) |
976 | + mock_readBody = self.patch(redfish_module, 'readBody') |
977 | + error = PartialDownloadError( |
978 | + response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'), |
979 | + code=HTTPStatus.NOT_FOUND) |
980 | + mock_readBody.return_value = fail(error) |
981 | + |
982 | + with ExpectedException(PartialDownloadError): |
983 | + yield driver.redfish_request(b"GET", uri, headers) |
984 | + self.assertThat(mock_readBody, MockCalledOnceWith( |
985 | + expected_headers)) |
986 | + |
987 | + @inlineCallbacks |
988 | + def test_redfish_request_raises_error_on_response_code_above_400(self): |
989 | + driver = RedfishPowerDriver() |
990 | + context = make_context() |
991 | + url = driver.get_url(context) |
992 | + uri = join(url, b"redfish/v1/Systems") |
993 | + headers = driver.make_auth_headers(**context) |
994 | + mock_agent = self.patch(redfish_module, 'Agent') |
995 | + mock_agent.return_value.request = Mock() |
996 | + expected_headers = Mock() |
997 | + expected_headers.code = HTTPStatus.BAD_REQUEST |
998 | + expected_headers.headers = "Testing Headers" |
999 | + mock_agent.return_value.request.return_value = succeed( |
1000 | + expected_headers) |
1001 | + mock_readBody = self.patch(redfish_module, 'readBody') |
1002 | + |
1003 | + with ExpectedException(PowerActionError): |
1004 | + yield driver.redfish_request(b"GET", uri, headers) |
1005 | + self.assertThat(mock_readBody, MockNotCalled()) |
1006 | + |
1007 | + @inlineCallbacks |
1008 | + def test_power_issues_power_reset(self): |
1009 | + driver = RedfishPowerDriver() |
1010 | + context = make_context() |
1011 | + power_change = factory.make_name('power_change') |
1012 | + url = driver.get_url(context) |
1013 | + headers = driver.make_auth_headers(**context) |
1014 | + node_id = context.get('node_id').encode('utf-8') |
1015 | + mock_file_body_producer = self.patch( |
1016 | + redfish_module, 'FileBodyProducer') |
1017 | + payload = FileBodyProducer( |
1018 | + BytesIO( |
1019 | + json.dumps( |
1020 | + { |
1021 | + 'ResetType': "%s" % power_change |
1022 | + }).encode('utf-8'))) |
1023 | + mock_file_body_producer.return_value = payload |
1024 | + mock_redfish_request = self.patch(driver, 'redfish_request') |
1025 | + expected_uri = join( |
1026 | + url, REDFISH_POWER_CONTROL_ENDPOINT % node_id) |
1027 | + yield driver.power(power_change, url, node_id, headers) |
1028 | + self.assertThat(mock_redfish_request, MockCalledOnceWith( |
1029 | + b"POST", expected_uri, headers, payload)) |
1030 | + |
1031 | + @inlineCallbacks |
1032 | + def test__set_pxe_boot(self): |
1033 | + driver = RedfishPowerDriver() |
1034 | + context = make_context() |
1035 | + url = driver.get_url(context) |
1036 | + node_id = context.get('node_id').encode('utf-8') |
1037 | + headers = driver.make_auth_headers(**context) |
1038 | + mock_file_body_producer = self.patch( |
1039 | + redfish_module, 'FileBodyProducer') |
1040 | + payload = FileBodyProducer( |
1041 | + BytesIO( |
1042 | + json.dumps( |
1043 | + { |
1044 | + 'Boot': { |
1045 | + 'BootSourceOverrideEnabled': "Once", |
1046 | + 'BootSourceOverrideTarget': "Pxe" |
1047 | + } |
1048 | + }).encode('utf-8'))) |
1049 | + mock_file_body_producer.return_value = payload |
1050 | + mock_redfish_request = self.patch(driver, 'redfish_request') |
1051 | + |
1052 | + yield driver.set_pxe_boot(url, node_id, headers) |
1053 | + self.assertThat(mock_redfish_request, MockCalledOnceWith( |
1054 | + b"PATCH", join(url, b"redfish/v1/Systems/%s/" % node_id), |
1055 | + headers, payload)) |
1056 | + |
1057 | + @inlineCallbacks |
1058 | + def test__power_on(self): |
1059 | + driver = RedfishPowerDriver() |
1060 | + system_id = factory.make_name('system_id') |
1061 | + context = make_context() |
1062 | + url = driver.get_url(context) |
1063 | + headers = driver.make_auth_headers(**context) |
1064 | + node_id = context.get('node_id').encode('utf-8') |
1065 | + mock_set_pxe_boot = self.patch(driver, 'set_pxe_boot') |
1066 | + mock_power_query = self.patch(driver, 'power_query') |
1067 | + mock_power_query.return_value = "on" |
1068 | + mock_power = self.patch(driver, 'power') |
1069 | + |
1070 | + yield driver.power_on(system_id, context) |
1071 | + self.assertThat(mock_set_pxe_boot, MockCalledOnceWith( |
1072 | + url, node_id, headers)) |
1073 | + self.assertThat(mock_power_query, MockCalledOnceWith( |
1074 | + system_id, context)) |
1075 | + self.assertThat(mock_power, MockCallsMatch( |
1076 | + call("ForceOff", url, node_id, headers), |
1077 | + call("On", url, node_id, headers))) |
1078 | + |
1079 | + @inlineCallbacks |
1080 | + def test__power_off(self): |
1081 | + driver = RedfishPowerDriver() |
1082 | + system_id = factory.make_name('system_id') |
1083 | + context = make_context() |
1084 | + url = driver.get_url(context) |
1085 | + headers = driver.make_auth_headers(**context) |
1086 | + node_id = context.get('node_id').encode('utf-8') |
1087 | + mock_set_pxe_boot = self.patch(driver, 'set_pxe_boot') |
1088 | + mock_power = self.patch(driver, 'power') |
1089 | + |
1090 | + yield driver.power_off(system_id, context) |
1091 | + self.assertThat(mock_set_pxe_boot, MockCalledOnceWith( |
1092 | + url, node_id, headers)) |
1093 | + self.assertThat(mock_power, MockCalledOnceWith( |
1094 | + "ForceOff", url, node_id, headers)) |
1095 | + |
1096 | + @inlineCallbacks |
1097 | + def test_power_query_queries_on(self): |
1098 | + driver = RedfishPowerDriver() |
1099 | + power_change = "On" |
1100 | + system_id = factory.make_name('system_id') |
1101 | + context = make_context() |
1102 | + mock_redfish_request = self.patch(driver, 'redfish_request') |
1103 | + NODE_POWERED_ON = deepcopy(SAMPLE_JSON_SYSTEM) |
1104 | + NODE_POWERED_ON['PowerState'] = "On" |
1105 | + mock_redfish_request.return_value = (NODE_POWERED_ON, None) |
1106 | + power_state = yield driver.power_query(system_id, context) |
1107 | + self.assertEquals(power_state, power_change.lower()) |
1108 | + |
1109 | + @inlineCallbacks |
1110 | + def test_power_query_queries_off(self): |
1111 | + driver = RedfishPowerDriver() |
1112 | + power_change = "Off" |
1113 | + system_id = factory.make_name('system_id') |
1114 | + context = make_context() |
1115 | + mock_redfish_request = self.patch(driver, 'redfish_request') |
1116 | + mock_redfish_request.return_value = (SAMPLE_JSON_SYSTEM, None) |
1117 | + power_state = yield driver.power_query(system_id, context) |
1118 | + self.assertEquals(power_state, power_change.lower()) |
UNIT TESTS power-driver lp:~newell-jensen/maas/+git/maas into -b master lp:~maas-committers/maas
-b redfish-
STATUS: SUCCESS 2e01317699a589b 15d87f7b6e
COMMIT: 3e24f9989a015b7