Merge ~newell-jensen/maas:redfish-power-driver into maas:master

Proposed by Newell Jensen
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)
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.

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b redfish-power-driver lp:~newell-jensen/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 3e24f9989a015b72e01317699a589b15d87f7b6e

review: Approve
Revision history for this message
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.

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

UNIT TESTS
-b redfish-power-driver lp:~newell-jensen/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci-jenkins.internal:8080/job/maas/job/branch-tester/4681/console
COMMIT: 28214014ad692c6612e41f345b356eed42dfbf3b

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

UNIT TESTS
-b redfish-power-driver lp:~newell-jensen/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: a7ee1527a9fe9c0e900d66eb39e42a5fb52d7122

review: Approve
Revision history for this message
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.

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

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/provisioningserver/drivers/pod/rsd.py b/src/provisioningserver/drivers/pod/rsd.py
2index 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(
178diff --git a/src/provisioningserver/drivers/pod/tests/test_rsd.py b/src/provisioningserver/drivers/pod/tests/test_rsd.py
179index 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):
431diff --git a/src/provisioningserver/drivers/power/redfish.py b/src/provisioningserver/drivers/power/redfish.py
432new file mode 100644
433index 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()
654diff --git a/src/provisioningserver/drivers/power/registry.py b/src/provisioningserver/drivers/power/registry.py
655index 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(),
674diff --git a/src/provisioningserver/drivers/power/tests/test_redfish.py b/src/provisioningserver/drivers/power/tests/test_redfish.py
675new file mode 100644
676index 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())

Subscribers

People subscribed via source and target branches