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
diff --git a/src/provisioningserver/drivers/pod/rsd.py b/src/provisioningserver/drivers/pod/rsd.py
index 97c1764..8230a12 100644
--- a/src/provisioningserver/drivers/pod/rsd.py
+++ b/src/provisioningserver/drivers/pod/rsd.py
@@ -1,4 +1,4 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the1# Copyright 2017-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Rack Scale Design pod driver."""4"""Rack Scale Design pod driver."""
@@ -7,7 +7,6 @@ __all__ = [
7 'RSDPodDriver',7 'RSDPodDriver',
8 ]8 ]
99
10from base64 import b64encode
11from http import HTTPStatus10from http import HTTPStatus
12from io import BytesIO11from io import BytesIO
13import json12import json
@@ -27,9 +26,13 @@ from provisioningserver.drivers.pod import (
27 DiscoveredPod,26 DiscoveredPod,
28 DiscoveredPodHints,27 DiscoveredPodHints,
29 PodActionError,28 PodActionError,
30 PodDriver,29 PodDriverBase,
31 PodFatalError,30 PodFatalError,
32)31)
32from provisioningserver.drivers.power.redfish import (
33 RedfishPowerDriverBase,
34 WebClientContextFactory,
35)
33from provisioningserver.logger import get_maas_logger36from provisioningserver.logger import get_maas_logger
34from provisioningserver.rpc.exceptions import PodInvalidResources37from provisioningserver.rpc.exceptions import PodInvalidResources
35from provisioningserver.utils.twisted import (38from provisioningserver.utils.twisted import (
@@ -37,23 +40,19 @@ from provisioningserver.utils.twisted import (
37 pause,40 pause,
38)41)
39from twisted.internet import reactor42from twisted.internet import reactor
40from twisted.internet._sslverify import (
41 ClientTLSOptions,
42 OpenSSLCertificateOptions,
43)
44from twisted.internet.defer import inlineCallbacks43from twisted.internet.defer import inlineCallbacks
45from twisted.web.client import (44from twisted.web.client import (
46 Agent,45 Agent,
47 BrowserLikePolicyForHTTPS,
48 FileBodyProducer,46 FileBodyProducer,
49 PartialDownloadError,47 PartialDownloadError,
50 readBody,48 readBody,
51)49)
52from twisted.web.http_headers import Headers
5350
5451
55maaslog = get_maas_logger("drivers.pod.rsd")52maaslog = get_maas_logger("drivers.pod.rsd")
5653
54RSD_POWER_CONTROL_ENDPOINT = b"redfish/v1/Nodes/%s/Actions/ComposedNode.Reset"
55
57# RSD stores the architecture with a different56# RSD stores the architecture with a different
58# label then MAAS. This maps RSD architecture to57# label then MAAS. This maps RSD architecture to
59# MAAS architecture.58# MAAS architecture.
@@ -76,19 +75,12 @@ RSD_NODE_POWER_STATE = {
76 }75 }
7776
7877
79class WebClientContextFactory(BrowserLikePolicyForHTTPS):78class RSDPodDriver(RedfishPowerDriverBase, PodDriverBase):
80
81 def creatorForNetloc(self, hostname, port):
82 opts = ClientTLSOptions(
83 hostname.decode("ascii"),
84 OpenSSLCertificateOptions(verify=False).getContext())
85 # This forces Twisted to not validate the hostname of the certificate.
86 opts._ctx.set_info_callback(lambda *args: None)
87 return opts
88
8979
90class RSDPodDriver(PodDriver):80 chassis = True # Pods are always a chassis
9181
82 # RSDPodDriver inherits from RedfishPowerDriver.
83 # Power parameters will need to be changed to reflect this.
92 name = 'rsd'84 name = 'rsd'
93 description = "Rack Scale Design"85 description = "Rack Scale Design"
94 settings = [86 settings = [
@@ -108,26 +100,6 @@ class RSDPodDriver(PodDriver):
108 # no required packages100 # no required packages
109 return []101 return []
110102
111 def get_url(self, context):
112 """Return url for the pod."""
113 url = context.get('power_address')
114 if "https" not in url and "http" not in url:
115 # Prepend https
116 url = join("https://", url)
117 return url.encode('utf-8')
118
119 def make_auth_headers(self, power_user, power_pass, **kwargs):
120 """Return authentication headers."""
121 creds = "%s:%s" % (power_user, power_pass)
122 authorization = b64encode(creds.encode('utf-8'))
123 return Headers(
124 {
125 b"User-Agent": [b"MAAS"],
126 b"Authorization": [b"Basic " + authorization],
127 b"Content-Type": [b"application/json; charset=utf-8"],
128 }
129 )
130
131 @asynchronous103 @asynchronous
132 def redfish_request(self, method, uri, headers=None, bodyProducer=None):104 def redfish_request(self, method, uri, headers=None, bodyProducer=None):
133 """Send the redfish request and return the response."""105 """Send the redfish request and return the response."""
@@ -140,7 +112,7 @@ class RSDPodDriver(PodDriver):
140112
141 def eb_catch_partial(failure):113 def eb_catch_partial(failure):
142 # Twisted is raising PartialDownloadError because the responses114 # Twisted is raising PartialDownloadError because the responses
143 # do not contains a Content-Length header. Since every response115 # do not contain a Content-Length header. Since every response
144 # holds the whole body we just take the result.116 # holds the whole body we just take the result.
145 failure.trap(PartialDownloadError)117 failure.trap(PartialDownloadError)
146 if int(failure.value.status) == HTTPStatus.OK:118 if int(failure.value.status) == HTTPStatus.OK:
@@ -150,7 +122,7 @@ class RSDPodDriver(PodDriver):
150122
151 def cb_json_decode(data):123 def cb_json_decode(data):
152 data = data.decode('utf-8')124 data = data.decode('utf-8')
153 # Only decode non-empty responses.125 # Only decode non-empty response bodies.
154 if data:126 if data:
155 response = json.loads(data)127 response = json.loads(data)
156 if "error" in response:128 if "error" in response:
@@ -1084,22 +1056,6 @@ class RSDPodDriver(PodDriver):
1084 return discovered_pod.hints1056 return discovered_pod.hints
10851057
1086 @inlineCallbacks1058 @inlineCallbacks
1087 def set_pxe_boot(self, url, node_id, headers):
1088 """Set the composed machine with node_id to PXE boot."""
1089 endpoint = b"redfish/v1/Nodes/%s" % node_id
1090 payload = FileBodyProducer(
1091 BytesIO(
1092 json.dumps(
1093 {
1094 'Boot': {
1095 'BootSourceOverrideEnabled': "Once",
1096 'BootSourceOverrideTarget': "Pxe"
1097 }
1098 }).encode('utf-8')))
1099 yield self.redfish_request(
1100 b"PATCH", join(url, endpoint), headers, payload)
1101
1102 @inlineCallbacks
1103 def get_composed_node_state(self, url, node_id, headers):1059 def get_composed_node_state(self, url, node_id, headers):
1104 """Return the `ComposedNodeState` of the composed machine."""1060 """Return the `ComposedNodeState` of the composed machine."""
1105 endpoint = b"redfish/v1/Nodes/%s" % node_id1061 endpoint = b"redfish/v1/Nodes/%s" % node_id
@@ -1149,8 +1105,24 @@ class RSDPodDriver(PodDriver):
1149 " of Failed." % node_id)1105 " of Failed." % node_id)
11501106
1151 @inlineCallbacks1107 @inlineCallbacks
1108 def set_pxe_boot(self, url, node_id, headers):
1109 """Set the composed machine with node_id to PXE boot."""
1110 endpoint = b"redfish/v1/Nodes/%s" % node_id
1111 payload = FileBodyProducer(
1112 BytesIO(
1113 json.dumps(
1114 {
1115 'Boot': {
1116 'BootSourceOverrideEnabled': "Once",
1117 'BootSourceOverrideTarget': "Pxe"
1118 }
1119 }).encode('utf-8')))
1120 yield self.redfish_request(
1121 b"PATCH", join(url, endpoint), headers, payload)
1122
1123 @inlineCallbacks
1152 def power(self, power_change, url, node_id, headers):1124 def power(self, power_change, url, node_id, headers):
1153 endpoint = b"redfish/v1/Nodes/%s/Actions/ComposedNode.Reset" % node_id1125 endpoint = RSD_POWER_CONTROL_ENDPOINT % node_id
1154 payload = FileBodyProducer(1126 payload = FileBodyProducer(
1155 BytesIO(1127 BytesIO(
1156 json.dumps(1128 json.dumps(
diff --git a/src/provisioningserver/drivers/pod/tests/test_rsd.py b/src/provisioningserver/drivers/pod/tests/test_rsd.py
index b3464c5..b4cc83c 100644
--- a/src/provisioningserver/drivers/pod/tests/test_rsd.py
+++ b/src/provisioningserver/drivers/pod/tests/test_rsd.py
@@ -1,21 +1,17 @@
1# Copyright 2017 Canonical Ltd. This software is licensed under the1# Copyright 2017-2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4"""Tests for `provisioningserver.drivers.pod.rsd`."""4"""Tests for `provisioningserver.drivers.pod.rsd`."""
55
6__all__ = []6__all__ = []
77
8from base64 import b64encode
9from copy import deepcopy8from copy import deepcopy
10from http import HTTPStatus9from http import HTTPStatus
11from io import BytesIO10from io import BytesIO
12import json11import json
13from os.path import join12from os.path import join
14import random13import random
15from unittest.mock import (14from unittest.mock import call
16 call,
17 Mock,
18)
1915
20from maastesting.factory import factory16from maastesting.factory import factory
21from maastesting.matchers import (17from maastesting.matchers import (
@@ -45,7 +41,6 @@ from provisioningserver.drivers.pod.rsd import (
45 RSD_NODE_POWER_STATE,41 RSD_NODE_POWER_STATE,
46 RSD_SYSTEM_POWER_STATE,42 RSD_SYSTEM_POWER_STATE,
47 RSDPodDriver,43 RSDPodDriver,
48 WebClientContextFactory,
49)44)
50import provisioningserver.drivers.pod.rsd as rsd_module45import provisioningserver.drivers.pod.rsd as rsd_module
51from provisioningserver.rpc.exceptions import PodInvalidResources46from provisioningserver.rpc.exceptions import PodInvalidResources
@@ -57,12 +52,7 @@ from testtools.matchers import (
57 MatchesListwise,52 MatchesListwise,
58 MatchesStructure,53 MatchesStructure,
59)54)
60from twisted.internet._sslverify import ClientTLSOptions55from twisted.internet.defer import inlineCallbacks
61from twisted.internet.defer import (
62 fail,
63 inlineCallbacks,
64 succeed,
65)
66from twisted.web.client import (56from twisted.web.client import (
67 FileBodyProducer,57 FileBodyProducer,
68 PartialDownloadError,58 PartialDownloadError,
@@ -790,16 +780,6 @@ def make_discovered_pod(
790 return discovered_pod780 return discovered_pod
791781
792782
793class TestWebClientContextFactory(MAASTestCase):
794
795 def test_creatorForNetloc_returns_tls_options(self):
796 hostname = factory.make_name('hostname').encode('utf-8')
797 port = random.randint(1000, 2000)
798 contextFactory = WebClientContextFactory()
799 opts = contextFactory.creatorForNetloc(hostname, port)
800 self.assertIsInstance(opts, ClientTLSOptions)
801
802
803class TestRSDPodDriver(MAASTestCase):783class TestRSDPodDriver(MAASTestCase):
804784
805 run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)785 run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)
@@ -810,116 +790,6 @@ class TestRSDPodDriver(MAASTestCase):
810 missing = driver.detect_missing_packages()790 missing = driver.detect_missing_packages()
811 self.assertItemsEqual([], missing)791 self.assertItemsEqual([], missing)
812792
813 def test_get_url_with_ip(self):
814 driver = RSDPodDriver()
815 context = make_context()
816 ip = context.get('power_address').encode('utf-8')
817 expected_url = b"https://%s" % ip
818 url = driver.get_url(context)
819 self.assertEqual(expected_url, url)
820
821 def test_get_url_with_https(self):
822 driver = RSDPodDriver()
823 context = make_context()
824 context['power_address'] = join(
825 "https://", context['power_address'])
826 expected_url = context.get('power_address').encode('utf-8')
827 url = driver.get_url(context)
828 self.assertEqual(expected_url, url)
829
830 def test_get_url_with_http(self):
831 driver = RSDPodDriver()
832 context = make_context()
833 context['power_address'] = join(
834 "http://", context['power_address'])
835 expected_url = context.get('power_address').encode('utf-8')
836 url = driver.get_url(context)
837 self.assertEqual(expected_url, url)
838
839 def test__make_auth_headers(self):
840 power_user = factory.make_name('power_user')
841 power_pass = factory.make_name('power_pass')
842 creds = "%s:%s" % (power_user, power_pass)
843 authorization = b64encode(creds.encode('utf-8'))
844 attributes = {
845 b"User-Agent": [b"MAAS"],
846 b"Authorization": [b"Basic " + authorization],
847 b"Content-Type": [b"application/json; charset=utf-8"],
848 }
849 driver = RSDPodDriver()
850 headers = driver.make_auth_headers(power_user, power_pass)
851 self.assertEquals(headers, Headers(attributes))
852
853 @inlineCallbacks
854 def test_redfish_request_renders_response(self):
855 driver = RSDPodDriver()
856 context = make_context()
857 url = driver.get_url(context)
858 uri = join(url, b"redfish/v1/Systems")
859 headers = driver.make_auth_headers(**context)
860 mock_agent = self.patch(rsd_module, 'Agent')
861 mock_agent.return_value.request = Mock()
862 expected_headers = Mock()
863 expected_headers.headers = "Testing Headers"
864 mock_agent.return_value.request.return_value = succeed(
865 expected_headers)
866 mock_readBody = self.patch(rsd_module, 'readBody')
867 mock_readBody.return_value = succeed(
868 json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'))
869 expected_response = SAMPLE_JSON_SYSTEMS
870
871 response, headers = yield driver.redfish_request(b"GET", uri, headers)
872 self.assertEquals(expected_response, response)
873 self.assertEquals(expected_headers.headers, headers)
874
875 @inlineCallbacks
876 def test_redfish_request_continues_partial_download_error(self):
877 driver = RSDPodDriver()
878 context = make_context()
879 url = driver.get_url(context)
880 uri = join(url, b"redfish/v1/Systems")
881 headers = driver.make_auth_headers(**context)
882 mock_agent = self.patch(rsd_module, 'Agent')
883 mock_agent.return_value.request = Mock()
884 expected_headers = Mock()
885 expected_headers.headers = "Testing Headers"
886 mock_agent.return_value.request.return_value = succeed(
887 expected_headers)
888 mock_readBody = self.patch(rsd_module, 'readBody')
889 error = PartialDownloadError(
890 response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'),
891 code=HTTPStatus.OK)
892 mock_readBody.return_value = fail(error)
893 expected_response = SAMPLE_JSON_SYSTEMS
894
895 response, headers = yield driver.redfish_request(b"GET", uri, headers)
896 self.assertEquals(expected_response, response)
897 self.assertEquals(expected_headers.headers, headers)
898
899 @inlineCallbacks
900 def test_redfish_request_raises_failures(self):
901 driver = RSDPodDriver()
902 context = make_context()
903 url = driver.get_url(context)
904 uri = join(url, b"redfish/v1/Systems")
905 headers = driver.make_auth_headers(**context)
906 mock_agent = self.patch(rsd_module, 'Agent')
907 mock_agent.return_value.request = Mock()
908 expected_headers = Mock()
909 expected_headers.headers = "Testing Headers"
910 mock_agent.return_value.request.return_value = succeed(
911 expected_headers)
912 mock_readBody = self.patch(rsd_module, 'readBody')
913 error = PartialDownloadError(
914 response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'),
915 code=HTTPStatus.NOT_FOUND)
916 mock_readBody.return_value = fail(error)
917
918 with ExpectedException(PartialDownloadError):
919 yield driver.redfish_request(b"GET", uri, headers)
920 self.assertThat(mock_readBody, MockCalledOnceWith(
921 expected_headers))
922
923 @inlineCallbacks793 @inlineCallbacks
924 def test__list_resources(self):794 def test__list_resources(self):
925 driver = RSDPodDriver()795 driver = RSDPodDriver()
@@ -2088,32 +1958,6 @@ class TestRSDPodDriver(MAASTestCase):
2088 discovered_pod))1958 discovered_pod))
20891959
2090 @inlineCallbacks1960 @inlineCallbacks
2091 def test__set_pxe_boot(self):
2092 driver = RSDPodDriver()
2093 context = make_context()
2094 url = driver.get_url(context)
2095 node_id = context.get('node_id').encode('utf-8')
2096 headers = driver.make_auth_headers(**context)
2097 mock_file_body_producer = self.patch(
2098 rsd_module, 'FileBodyProducer')
2099 payload = FileBodyProducer(
2100 BytesIO(
2101 json.dumps(
2102 {
2103 'Boot': {
2104 'BootSourceOverrideEnabled': "Once",
2105 'BootSourceOverrideTarget': "Pxe"
2106 }
2107 }).encode('utf-8')))
2108 mock_file_body_producer.return_value = payload
2109 mock_redfish_request = self.patch(driver, 'redfish_request')
2110
2111 yield driver.set_pxe_boot(url, node_id, headers)
2112 self.assertThat(mock_redfish_request, MockCalledOnceWith(
2113 b"PATCH", join(url, b"redfish/v1/Nodes/%s" % node_id),
2114 headers, payload))
2115
2116 @inlineCallbacks
2117 def test__get_composed_node_state(self):1961 def test__get_composed_node_state(self):
2118 driver = RSDPodDriver()1962 driver = RSDPodDriver()
2119 context = make_context()1963 context = make_context()
@@ -2220,10 +2064,9 @@ class TestRSDPodDriver(MAASTestCase):
2220 url, node_id, headers))2064 url, node_id, headers))
22212065
2222 @inlineCallbacks2066 @inlineCallbacks
2223 def test_power_issues_power_reset(self):2067 def test__set_pxe_boot(self):
2224 driver = RSDPodDriver()2068 driver = RSDPodDriver()
2225 context = make_context()2069 context = make_context()
2226 power_change = factory.make_name('power_change')
2227 url = driver.get_url(context)2070 url = driver.get_url(context)
2228 node_id = context.get('node_id').encode('utf-8')2071 node_id = context.get('node_id').encode('utf-8')
2229 headers = driver.make_auth_headers(**context)2072 headers = driver.make_auth_headers(**context)
@@ -2233,15 +2076,18 @@ class TestRSDPodDriver(MAASTestCase):
2233 BytesIO(2076 BytesIO(
2234 json.dumps(2077 json.dumps(
2235 {2078 {
2236 'ResetType': "%s" % power_change2079 'Boot': {
2080 'BootSourceOverrideEnabled': "Once",
2081 'BootSourceOverrideTarget': "Pxe"
2082 }
2237 }).encode('utf-8')))2083 }).encode('utf-8')))
2238 mock_file_body_producer.return_value = payload2084 mock_file_body_producer.return_value = payload
2239 mock_redfish_request = self.patch(driver, 'redfish_request')2085 mock_redfish_request = self.patch(driver, 'redfish_request')
2240 expected_uri = join(2086
2241 url, b"redfish/v1/Nodes/%s/Actions/ComposedNode.Reset" % node_id)2087 yield driver.set_pxe_boot(url, node_id, headers)
2242 yield driver.power(power_change, url, node_id, headers)
2243 self.assertThat(mock_redfish_request, MockCalledOnceWith(2088 self.assertThat(mock_redfish_request, MockCalledOnceWith(
2244 b"POST", expected_uri, headers, payload))2089 b"PATCH", join(url, b"redfish/v1/Nodes/%s" % node_id),
2090 headers, payload))
22452091
2246 @inlineCallbacks2092 @inlineCallbacks
2247 def test__power_on(self):2093 def test__power_on(self):
diff --git a/src/provisioningserver/drivers/power/redfish.py b/src/provisioningserver/drivers/power/redfish.py
2248new file mode 1006442094new file mode 100644
index 0000000..5b47e21
--- /dev/null
+++ b/src/provisioningserver/drivers/power/redfish.py
@@ -0,0 +1,217 @@
1# Copyright 2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Redfish Power Driver."""
5
6__all__ = [
7 'RedfishPowerDriver',
8 ]
9
10from base64 import b64encode
11from http import HTTPStatus
12from io import BytesIO
13import json
14from os.path import join
15
16from provisioningserver.drivers import (
17 make_ip_extractor,
18 make_setting_field,
19 SETTING_SCOPE,
20)
21from provisioningserver.drivers.power import (
22 PowerActionError,
23 PowerDriver,
24)
25from provisioningserver.utils.twisted import asynchronous
26from twisted.internet import reactor
27from twisted.internet._sslverify import (
28 ClientTLSOptions,
29 OpenSSLCertificateOptions,
30)
31from twisted.internet.defer import inlineCallbacks
32from twisted.web.client import (
33 Agent,
34 BrowserLikePolicyForHTTPS,
35 FileBodyProducer,
36 PartialDownloadError,
37 readBody,
38)
39from twisted.web.http_headers import Headers
40
41
42REDFISH_POWER_CONTROL_ENDPOINT = (
43 b"redfish/v1/Systems/%s/Actions/ComputerSystem.Reset/")
44
45REDFISH_SYSTEMS_ENDPOINT = b"redfish/v1/Systems/%s/"
46
47
48class WebClientContextFactory(BrowserLikePolicyForHTTPS):
49
50 def creatorForNetloc(self, hostname, port):
51 opts = ClientTLSOptions(
52 hostname.decode("ascii"),
53 OpenSSLCertificateOptions(verify=False).getContext())
54 # This forces Twisted to not validate the hostname of the certificate.
55 opts._ctx.set_info_callback(lambda *args: None)
56 return opts
57
58
59class RedfishPowerDriverBase(PowerDriver):
60
61 def get_url(self, context):
62 """Return url for the pod."""
63 url = context.get('power_address')
64 if "https" not in url and "http" not in url:
65 # Prepend https
66 url = join("https://", url)
67 return url.encode('utf-8')
68
69 def make_auth_headers(self, power_user, power_pass, **kwargs):
70 """Return authentication headers."""
71 creds = "%s:%s" % (power_user, power_pass)
72 authorization = b64encode(creds.encode('utf-8'))
73 return Headers(
74 {
75 b"User-Agent": [b"MAAS"],
76 b"Authorization": [b"Basic " + authorization],
77 b"Content-Type": [b"application/json; charset=utf-8"],
78 }
79 )
80
81 def process_redfish_context(self, context):
82 """Process Redfish power driver context."""
83 url = self.get_url(context)
84 node_id = context.get('node_id').encode('utf-8')
85 headers = self.make_auth_headers(**context)
86 return url, node_id, headers
87
88 @asynchronous
89 def redfish_request(self, method, uri, headers=None, bodyProducer=None):
90 """Send the redfish request and return the response."""
91 agent = Agent(reactor, contextFactory=WebClientContextFactory())
92 d = agent.request(
93 method, uri, headers=headers, bodyProducer=bodyProducer)
94
95 def render_response(response):
96 """Render the HTTPS response received."""
97
98 def eb_catch_partial(failure):
99 # Twisted is raising PartialDownloadError because the responses
100 # do not contain a Content-Length header. Since every response
101 # holds the whole body we just take the result.
102 failure.trap(PartialDownloadError)
103 if int(failure.value.status) == HTTPStatus.OK:
104 return failure.value.response
105 else:
106 return failure
107
108 def cb_json_decode(data):
109 data = data.decode('utf-8')
110 # Only decode non-empty response bodies.
111 if data:
112 return json.loads(data)
113
114 def cb_attach_headers(data, headers):
115 return data, headers
116
117 # Error out if the response has a status code of 400 or above.
118 if response.code >= int(HTTPStatus.BAD_REQUEST):
119 raise PowerActionError(
120 "Redfish request failed with response status code:"
121 " %s." % response.code)
122
123 d = readBody(response)
124 d.addErrback(eb_catch_partial)
125 d.addCallback(cb_json_decode)
126 d.addCallback(cb_attach_headers, headers=response.headers)
127 return d
128
129 d.addCallback(render_response)
130 return d
131
132
133class RedfishPowerDriver(RedfishPowerDriverBase):
134
135 chassis = True # Redfish API endpoints can be probed and enlisted.
136
137 name = 'redfish'
138 description = "Redfish"
139 settings = [
140 make_setting_field(
141 'power_address', "Redfish address", required=True),
142 make_setting_field('power_user', "Redfish user", required=True),
143 make_setting_field(
144 'power_pass', "Redfish password",
145 field_type='password', required=True),
146 make_setting_field(
147 'node_id', "Node ID",
148 scope=SETTING_SCOPE.NODE, required=True),
149 ]
150 ip_extractor = make_ip_extractor('power_address')
151
152 def detect_missing_packages(self):
153 # no required packages
154 return []
155
156 @inlineCallbacks
157 def set_pxe_boot(self, url, node_id, headers):
158 """Set the machine with node_id to PXE boot."""
159 endpoint = REDFISH_SYSTEMS_ENDPOINT % node_id
160 payload = FileBodyProducer(
161 BytesIO(
162 json.dumps(
163 {
164 'Boot': {
165 'BootSourceOverrideEnabled': "Once",
166 'BootSourceOverrideTarget': "Pxe"
167 }
168 }).encode('utf-8')))
169 yield self.redfish_request(
170 b"PATCH", join(url, endpoint), headers, payload)
171
172 @inlineCallbacks
173 def power(self, power_change, url, node_id, headers):
174 """Issue `power` command."""
175 endpoint = REDFISH_POWER_CONTROL_ENDPOINT % node_id
176 payload = FileBodyProducer(
177 BytesIO(
178 json.dumps(
179 {
180 'Action': "Reset",
181 'ResetType': "%s" % power_change
182 }).encode('utf-8')))
183 yield self.redfish_request(
184 b"POST", join(url, endpoint), headers, payload)
185
186 @asynchronous
187 @inlineCallbacks
188 def power_on(self, system_id, context):
189 """Power on machine."""
190 url, node_id, headers = self.process_redfish_context(context)
191 power_state = yield self.power_query(system_id, context)
192 # Power off the machine if currently on.
193 if power_state == 'on':
194 yield self.power("ForceOff", url, node_id, headers)
195 # Set to PXE boot.
196 yield self.set_pxe_boot(url, node_id, headers)
197 # Power on the machine.
198 yield self.power("On", url, node_id, headers)
199
200 @asynchronous
201 @inlineCallbacks
202 def power_off(self, system_id, context):
203 """Power off machine."""
204 url, node_id, headers = self.process_redfish_context(context)
205 # Set to PXE boot.
206 yield self.set_pxe_boot(url, node_id, headers)
207 # Power off the machine.
208 yield self.power("ForceOff", url, node_id, headers)
209
210 @asynchronous
211 @inlineCallbacks
212 def power_query(self, system_id, context):
213 """Power query machine."""
214 url, node_id, headers = self.process_redfish_context(context)
215 uri = join(url, REDFISH_SYSTEMS_ENDPOINT % node_id)
216 node_data, _ = yield self.redfish_request(b"GET", uri, headers)
217 return node_data.get('PowerState').lower()
diff --git a/src/provisioningserver/drivers/power/registry.py b/src/provisioningserver/drivers/power/registry.py
index 14a1136..78ddb92 100644
--- a/src/provisioningserver/drivers/power/registry.py
+++ b/src/provisioningserver/drivers/power/registry.py
@@ -22,6 +22,7 @@ from provisioningserver.drivers.power.mscm import MSCMPowerDriver
22from provisioningserver.drivers.power.msftocs import MicrosoftOCSPowerDriver22from provisioningserver.drivers.power.msftocs import MicrosoftOCSPowerDriver
23from provisioningserver.drivers.power.nova import NovaPowerDriver23from provisioningserver.drivers.power.nova import NovaPowerDriver
24from provisioningserver.drivers.power.recs import RECSPowerDriver24from provisioningserver.drivers.power.recs import RECSPowerDriver
25from provisioningserver.drivers.power.redfish import RedfishPowerDriver
25from provisioningserver.drivers.power.seamicro import SeaMicroPowerDriver26from provisioningserver.drivers.power.seamicro import SeaMicroPowerDriver
26from provisioningserver.drivers.power.ucsm import UCSMPowerDriver27from provisioningserver.drivers.power.ucsm import UCSMPowerDriver
27from provisioningserver.drivers.power.virsh import VirshPowerDriver28from provisioningserver.drivers.power.virsh import VirshPowerDriver
@@ -61,6 +62,7 @@ power_drivers = [
61 MicrosoftOCSPowerDriver(),62 MicrosoftOCSPowerDriver(),
62 NovaPowerDriver(),63 NovaPowerDriver(),
63 RECSPowerDriver(),64 RECSPowerDriver(),
65 RedfishPowerDriver(),
64 SeaMicroPowerDriver(),66 SeaMicroPowerDriver(),
65 UCSMPowerDriver(),67 UCSMPowerDriver(),
66 VirshPowerDriver(),68 VirshPowerDriver(),
diff --git a/src/provisioningserver/drivers/power/tests/test_redfish.py b/src/provisioningserver/drivers/power/tests/test_redfish.py
67new file mode 10064469new file mode 100644
index 0000000..f6db6e3
--- /dev/null
+++ b/src/provisioningserver/drivers/power/tests/test_redfish.py
@@ -0,0 +1,439 @@
1# Copyright 2018 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Tests for `provisioningserver.drivers.power.redfish`."""
5
6__all__ = []
7
8
9from base64 import b64encode
10from copy import deepcopy
11from http import HTTPStatus
12from io import BytesIO
13import json
14from os.path import join
15import random
16from unittest.mock import (
17 call,
18 Mock,
19)
20
21from maastesting.factory import factory
22from maastesting.matchers import (
23 MockCalledOnceWith,
24 MockCallsMatch,
25 MockNotCalled,
26)
27from maastesting.testcase import (
28 MAASTestCase,
29 MAASTwistedRunTest,
30)
31from provisioningserver.drivers.power import PowerActionError
32from provisioningserver.drivers.power.redfish import (
33 REDFISH_POWER_CONTROL_ENDPOINT,
34 RedfishPowerDriver,
35 WebClientContextFactory,
36)
37import provisioningserver.drivers.power.redfish as redfish_module
38from testtools import ExpectedException
39from twisted.internet._sslverify import ClientTLSOptions
40from twisted.internet.defer import (
41 fail,
42 inlineCallbacks,
43 succeed,
44)
45from twisted.web.client import (
46 FileBodyProducer,
47 PartialDownloadError,
48)
49from twisted.web.http_headers import Headers
50
51
52SAMPLE_JSON_SYSTEMS = {
53 "@odata.context": "/redfish/v1/$metadata#Systems",
54 "@odata.count": 1,
55 "@odata.id": "/redfish/v1/Systems",
56 "@odata.type": "#ComputerSystem.1.0.0.ComputerSystemCollection",
57 "Description": "Collection of Computer Systems",
58 "Members": [
59 {
60 "@odata.id": "/redfish/v1/Systems/1"
61 }
62 ],
63 "Name": "Computer System Collection"
64}
65
66SAMPLE_JSON_SYSTEM = {
67 "@odata.context": "/redfish/v1/$metadata#Systems/Members/$entity",
68 "@odata.id": "/redfish/v1/Systems/1",
69 "@odata.type": "#ComputerSystem.1.0.0.ComputerSystem",
70 "Actions": {
71 "#ComputerSystem.Reset": {
72 "ResetType@Redfish.AllowableValues": [
73 "On",
74 "ForceOff",
75 "GracefulRestart",
76 "PushPowerButton",
77 "Nmi"
78 ],
79 "target": "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset"
80 }
81 },
82 "AssetTag": "",
83 "BiosVersion": "2.1.7",
84 "Boot": {
85 "BootSourceOverrideEnabled": "Once",
86 "BootSourceOverrideTarget": "None",
87 "BootSourceOverrideTarget@Redfish.AllowableValues": [
88 "None",
89 "Pxe",
90 "Floppy",
91 "Cd",
92 "Hdd",
93 "BiosSetup",
94 "Utilities",
95 "UefiTarget"
96 ],
97 "UefiTargetBootSourceOverride": ""
98 },
99 "Description": "Computer System which represents a machine.",
100 "EthernetInterfaces": {
101 "@odata.id": "/redfish/v1/Systems/1/EthernetInterfaces"
102 },
103 "HostName": "WORTHY-BOAR",
104 "Id": "1",
105 "IndicatorLED": "Off",
106 "Links": {
107 "Chassis": [
108 {
109 "@odata.id": "/redfish/v1/Chassis/1"
110 }
111 ],
112 "ManagedBy": [
113 {
114 "@odata.id": "/redfish/v1/Managers/iDRAC.Embedded.1"
115 }
116 ],
117 "PoweredBy": [
118 {
119 "@odata.id": "/redfish/v1/Chassis/1/Power/PowerSupplies/..."
120 },
121 {
122 "@odata.id": "/redfish/v1/Chassis/1/Power/PowerSupplies/..."
123 }
124 ]
125 },
126 "Manufacturer": "Dell Inc.",
127 "MemorySummary": {
128 "Status": {
129 "Health": "OK",
130 "HealthRollUp": "OK",
131 "State": "Enabled"
132 },
133 "TotalSystemMemoryGiB": 64
134 },
135 "Model": "PowerEdge R630",
136 "Name": "System",
137 "PartNumber": "02C2CPA01",
138 "PowerState": "Off",
139 "ProcessorSummary": {
140 "Count": 2,
141 "Model": "Intel(R) Xeon(R) CPU E5-2667 v4 @ 3.20GHz",
142 "Status": {
143 "Health": "Critical",
144 "HealthRollUp": "Critical",
145 "State": "Enabled"
146 }
147 },
148 "Processors": {
149 "@odata.id": "/redfish/v1/Systems/1/Processors"
150 },
151 "SKU": "7PW1RD2",
152 "SerialNumber": "CN7475166I0364",
153 "SimpleStorage": {
154 "@odata.id": "/redfish/v1/Systems/1/Storage/Controllers"
155 },
156 "Status": {
157 "Health": "Critical",
158 "HealthRollUp": "Critical",
159 "State": "Offline"
160 },
161 "SystemType": "Physical",
162 "UUID": "4c4c4544-0050-5710-8031-b7c04f524432"
163}
164
165
166def make_context():
167 return {
168 'power_address': factory.make_ipv4_address(),
169 'power_user': factory.make_name('power_user'),
170 'power_pass': factory.make_name('power_pass'),
171 'node_id': factory.make_name('node_id'),
172 }
173
174
175class TestWebClientContextFactory(MAASTestCase):
176
177 def test_creatorForNetloc_returns_tls_options(self):
178 hostname = factory.make_name('hostname').encode('utf-8')
179 port = random.randint(1000, 2000)
180 contextFactory = WebClientContextFactory()
181 opts = contextFactory.creatorForNetloc(hostname, port)
182 self.assertIsInstance(opts, ClientTLSOptions)
183
184
185class TestRedfishPowerDriver(MAASTestCase):
186
187 run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)
188
189 def test_missing_packages(self):
190 # there's nothing to check for, just confirm it returns []
191 driver = RedfishPowerDriver()
192 missing = driver.detect_missing_packages()
193 self.assertItemsEqual([], missing)
194
195 def test_get_url_with_ip(self):
196 driver = RedfishPowerDriver()
197 context = make_context()
198 ip = context.get('power_address').encode('utf-8')
199 expected_url = b"https://%s" % ip
200 url = driver.get_url(context)
201 self.assertEqual(expected_url, url)
202
203 def test_get_url_with_https(self):
204 driver = RedfishPowerDriver()
205 context = make_context()
206 context['power_address'] = join(
207 "https://", context['power_address'])
208 expected_url = context.get('power_address').encode('utf-8')
209 url = driver.get_url(context)
210 self.assertEqual(expected_url, url)
211
212 def test_get_url_with_http(self):
213 driver = RedfishPowerDriver()
214 context = make_context()
215 context['power_address'] = join(
216 "http://", context['power_address'])
217 expected_url = context.get('power_address').encode('utf-8')
218 url = driver.get_url(context)
219 self.assertEqual(expected_url, url)
220
221 def test__make_auth_headers(self):
222 power_user = factory.make_name('power_user')
223 power_pass = factory.make_name('power_pass')
224 creds = "%s:%s" % (power_user, power_pass)
225 authorization = b64encode(creds.encode('utf-8'))
226 attributes = {
227 b"User-Agent": [b"MAAS"],
228 b"Authorization": [b"Basic " + authorization],
229 b"Content-Type": [b"application/json; charset=utf-8"],
230 }
231 driver = RedfishPowerDriver()
232 headers = driver.make_auth_headers(power_user, power_pass)
233 self.assertEquals(headers, Headers(attributes))
234
235 @inlineCallbacks
236 def test_redfish_request_renders_response(self):
237 driver = RedfishPowerDriver()
238 context = make_context()
239 url = driver.get_url(context)
240 uri = join(url, b"redfish/v1/Systems")
241 headers = driver.make_auth_headers(**context)
242 mock_agent = self.patch(redfish_module, 'Agent')
243 mock_agent.return_value.request = Mock()
244 expected_headers = Mock()
245 expected_headers.code = HTTPStatus.OK
246 expected_headers.headers = "Testing Headers"
247 mock_agent.return_value.request.return_value = succeed(
248 expected_headers)
249 mock_readBody = self.patch(redfish_module, 'readBody')
250 mock_readBody.return_value = succeed(
251 json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'))
252 expected_response = SAMPLE_JSON_SYSTEMS
253
254 response, headers = yield driver.redfish_request(b"GET", uri, headers)
255 self.assertEquals(expected_response, response)
256 self.assertEquals(expected_headers.headers, headers)
257
258 @inlineCallbacks
259 def test_redfish_request_continues_partial_download_error(self):
260 driver = RedfishPowerDriver()
261 context = make_context()
262 url = driver.get_url(context)
263 uri = join(url, b"redfish/v1/Systems")
264 headers = driver.make_auth_headers(**context)
265 mock_agent = self.patch(redfish_module, 'Agent')
266 mock_agent.return_value.request = Mock()
267 expected_headers = Mock()
268 expected_headers.code = HTTPStatus.OK
269 expected_headers.headers = "Testing Headers"
270 mock_agent.return_value.request.return_value = succeed(
271 expected_headers)
272 mock_readBody = self.patch(redfish_module, 'readBody')
273 error = PartialDownloadError(
274 response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'),
275 code=HTTPStatus.OK)
276 mock_readBody.return_value = fail(error)
277 expected_response = SAMPLE_JSON_SYSTEMS
278
279 response, headers = yield driver.redfish_request(b"GET", uri, headers)
280 self.assertEquals(expected_response, response)
281 self.assertEquals(expected_headers.headers, headers)
282
283 @inlineCallbacks
284 def test_redfish_request_raises_failures(self):
285 driver = RedfishPowerDriver()
286 context = make_context()
287 url = driver.get_url(context)
288 uri = join(url, b"redfish/v1/Systems")
289 headers = driver.make_auth_headers(**context)
290 mock_agent = self.patch(redfish_module, 'Agent')
291 mock_agent.return_value.request = Mock()
292 expected_headers = Mock()
293 expected_headers.code = HTTPStatus.OK
294 expected_headers.headers = "Testing Headers"
295 mock_agent.return_value.request.return_value = succeed(
296 expected_headers)
297 mock_readBody = self.patch(redfish_module, 'readBody')
298 error = PartialDownloadError(
299 response=json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8'),
300 code=HTTPStatus.NOT_FOUND)
301 mock_readBody.return_value = fail(error)
302
303 with ExpectedException(PartialDownloadError):
304 yield driver.redfish_request(b"GET", uri, headers)
305 self.assertThat(mock_readBody, MockCalledOnceWith(
306 expected_headers))
307
308 @inlineCallbacks
309 def test_redfish_request_raises_error_on_response_code_above_400(self):
310 driver = RedfishPowerDriver()
311 context = make_context()
312 url = driver.get_url(context)
313 uri = join(url, b"redfish/v1/Systems")
314 headers = driver.make_auth_headers(**context)
315 mock_agent = self.patch(redfish_module, 'Agent')
316 mock_agent.return_value.request = Mock()
317 expected_headers = Mock()
318 expected_headers.code = HTTPStatus.BAD_REQUEST
319 expected_headers.headers = "Testing Headers"
320 mock_agent.return_value.request.return_value = succeed(
321 expected_headers)
322 mock_readBody = self.patch(redfish_module, 'readBody')
323
324 with ExpectedException(PowerActionError):
325 yield driver.redfish_request(b"GET", uri, headers)
326 self.assertThat(mock_readBody, MockNotCalled())
327
328 @inlineCallbacks
329 def test_power_issues_power_reset(self):
330 driver = RedfishPowerDriver()
331 context = make_context()
332 power_change = factory.make_name('power_change')
333 url = driver.get_url(context)
334 headers = driver.make_auth_headers(**context)
335 node_id = context.get('node_id').encode('utf-8')
336 mock_file_body_producer = self.patch(
337 redfish_module, 'FileBodyProducer')
338 payload = FileBodyProducer(
339 BytesIO(
340 json.dumps(
341 {
342 'ResetType': "%s" % power_change
343 }).encode('utf-8')))
344 mock_file_body_producer.return_value = payload
345 mock_redfish_request = self.patch(driver, 'redfish_request')
346 expected_uri = join(
347 url, REDFISH_POWER_CONTROL_ENDPOINT % node_id)
348 yield driver.power(power_change, url, node_id, headers)
349 self.assertThat(mock_redfish_request, MockCalledOnceWith(
350 b"POST", expected_uri, headers, payload))
351
352 @inlineCallbacks
353 def test__set_pxe_boot(self):
354 driver = RedfishPowerDriver()
355 context = make_context()
356 url = driver.get_url(context)
357 node_id = context.get('node_id').encode('utf-8')
358 headers = driver.make_auth_headers(**context)
359 mock_file_body_producer = self.patch(
360 redfish_module, 'FileBodyProducer')
361 payload = FileBodyProducer(
362 BytesIO(
363 json.dumps(
364 {
365 'Boot': {
366 'BootSourceOverrideEnabled': "Once",
367 'BootSourceOverrideTarget': "Pxe"
368 }
369 }).encode('utf-8')))
370 mock_file_body_producer.return_value = payload
371 mock_redfish_request = self.patch(driver, 'redfish_request')
372
373 yield driver.set_pxe_boot(url, node_id, headers)
374 self.assertThat(mock_redfish_request, MockCalledOnceWith(
375 b"PATCH", join(url, b"redfish/v1/Systems/%s/" % node_id),
376 headers, payload))
377
378 @inlineCallbacks
379 def test__power_on(self):
380 driver = RedfishPowerDriver()
381 system_id = factory.make_name('system_id')
382 context = make_context()
383 url = driver.get_url(context)
384 headers = driver.make_auth_headers(**context)
385 node_id = context.get('node_id').encode('utf-8')
386 mock_set_pxe_boot = self.patch(driver, 'set_pxe_boot')
387 mock_power_query = self.patch(driver, 'power_query')
388 mock_power_query.return_value = "on"
389 mock_power = self.patch(driver, 'power')
390
391 yield driver.power_on(system_id, context)
392 self.assertThat(mock_set_pxe_boot, MockCalledOnceWith(
393 url, node_id, headers))
394 self.assertThat(mock_power_query, MockCalledOnceWith(
395 system_id, context))
396 self.assertThat(mock_power, MockCallsMatch(
397 call("ForceOff", url, node_id, headers),
398 call("On", url, node_id, headers)))
399
400 @inlineCallbacks
401 def test__power_off(self):
402 driver = RedfishPowerDriver()
403 system_id = factory.make_name('system_id')
404 context = make_context()
405 url = driver.get_url(context)
406 headers = driver.make_auth_headers(**context)
407 node_id = context.get('node_id').encode('utf-8')
408 mock_set_pxe_boot = self.patch(driver, 'set_pxe_boot')
409 mock_power = self.patch(driver, 'power')
410
411 yield driver.power_off(system_id, context)
412 self.assertThat(mock_set_pxe_boot, MockCalledOnceWith(
413 url, node_id, headers))
414 self.assertThat(mock_power, MockCalledOnceWith(
415 "ForceOff", url, node_id, headers))
416
417 @inlineCallbacks
418 def test_power_query_queries_on(self):
419 driver = RedfishPowerDriver()
420 power_change = "On"
421 system_id = factory.make_name('system_id')
422 context = make_context()
423 mock_redfish_request = self.patch(driver, 'redfish_request')
424 NODE_POWERED_ON = deepcopy(SAMPLE_JSON_SYSTEM)
425 NODE_POWERED_ON['PowerState'] = "On"
426 mock_redfish_request.return_value = (NODE_POWERED_ON, None)
427 power_state = yield driver.power_query(system_id, context)
428 self.assertEquals(power_state, power_change.lower())
429
430 @inlineCallbacks
431 def test_power_query_queries_off(self):
432 driver = RedfishPowerDriver()
433 power_change = "Off"
434 system_id = factory.make_name('system_id')
435 context = make_context()
436 mock_redfish_request = self.patch(driver, 'redfish_request')
437 mock_redfish_request.return_value = (SAMPLE_JSON_SYSTEM, None)
438 power_state = yield driver.power_query(system_id, context)
439 self.assertEquals(power_state, power_change.lower())

Subscribers

People subscribed via source and target branches