Merge ~fwieffering/maas:tgt-redfish-bugfix into maas:master
- Git
- lp:~fwieffering/maas
- tgt-redfish-bugfix
- Merge into master
Status: | Merged |
---|---|
Approved by: | Blake Rouse |
Approved revision: | 1c395302c2e11011fd5f66702a7d122a3e2a6e5c |
Merge reported by: | MAAS Lander |
Merged at revision: | not available |
Proposed branch: | ~fwieffering/maas:tgt-redfish-bugfix |
Merge into: | maas:master |
Diff against target: |
252 lines (+114/-15) 4 files modified
src/maasserver/api/nodes.py (+1/-1) src/maasserver/clusterrpc/power.py (+1/-1) src/provisioningserver/drivers/power/redfish.py (+34/-12) src/provisioningserver/drivers/power/tests/test_redfish.py (+78/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Blake Rouse (community) | Approve | ||
MAAS Lander | Approve | ||
Review via email: mp+373131@code.launchpad.net |
Commit message
Fixes LP: #1845032 - Fixes issues that causes MAAS Redfish power driver to not work on some BMC's.
* removed trailing slashes from redfish power driver. This caused errors for some manufacturers implementations that we use
* catch json parsing errors and return a sensible error message if it fails to parse
* increase timeout on power query calls
* check state in redfish power driver power_off prior to submitting power_off request. Some redfish implementations we encountered errors if the state was already off and a power off request was submitted
Description of the change
We have switched to the redfish power driver for MaaS and encountered a few bugs that we needed to fix.
- removed trailing slashes from redfish power driver. This caused errors for some manufacturers implementations that we use
- catch json parsing errors and return a sensible error message if it fails to parse
- increase timeout on power query calls
- check state in redfish power driver power_off prior to submitting power_off request. Some redfish implementations we encountered errors if the state was already off and a power off request was submitted
Will comment with branch once bug is created
Blake Rouse (blake-rouse) wrote : | # |
I like the fixes, overall looks acceptable. But tests need to be updated and more need to be added to cover the changes.
Freddy (fwieffering) wrote : | # |
I will take care of that.
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b tgt-redfish-bugfix lp:~fwieffering/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: FAILED
LOG: http://
COMMIT: ecdda8fc9aa1548
Freddy (fwieffering) wrote : | # |
The tests should be all set. I don't know if this jenkins job is going to run again or not, but I have them passing.
Newell Jensen (newell-jensen) wrote : | # |
Some manufacturers require the trailing slash. We should probably tailor the driver to attempt both cases.
Blake Rouse (blake-rouse) wrote : | # |
Code change looks good and the tests look good. Only issue I see is that you have not signed the Canonical contributors agreement. Already having a launchpad account makes that really easy.
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b tgt-redfish-bugfix lp:~fwieffering/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: FAILED
LOG: http://
COMMIT: d0470f99ac1de4f
Freddy (fwieffering) wrote : | # |
I have signed the canonical contributors agreement
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b tgt-redfish-bugfix lp:~fwieffering/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: SUCCESS
COMMIT: ae549d3b087f66e
Blake Rouse (blake-rouse) wrote : | # |
Seems that @newell-jensen, our resident power driver expert and original author of the Redfish driver says that some BMC's require the '/' at the end. We need to confirm this is the case before landing this.
Freddy (fwieffering) wrote : | # |
I can add a retry to add a trailing slash if the power driver call fails for any reason. Would you like that approach or is this something that can be investigated?
Blake Rouse (blake-rouse) wrote : | # |
I would hope in the case that a ending slash is required the BMC would return a 301 redirect to the URL with a slash append, we should ensure that we handle a 301 or 302 redirect.
If it returns a 404 we could retry with a pending slash, just to be sure. Seems like its better to be defensive, sense this is not documented in the specification on the proper way.
Freddy (fwieffering) wrote : | # |
@blake-rouse @newell-jensen I've added a retry with a trailing slash if the request fails with a 404 and the uri does not include a trailing slash
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b tgt-redfish-bugfix lp:~fwieffering/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: SUCCESS
COMMIT: 1c395302c2e1101
Freddy (fwieffering) wrote : | # |
Just bumping this in case it fell off the radar
Blake Rouse (blake-rouse) wrote : | # |
Looks good, thanks for the fixes and the tests!
MAAS Lander (maas-lander) wrote : | # |
LANDING
-b tgt-redfish-bugfix lp:~fwieffering/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: FAILED BUILD
LOG: http://
Freddy (fwieffering) wrote : | # |
Is there anything more I need to do here?
Preview Diff
1 | diff --git a/src/maasserver/api/nodes.py b/src/maasserver/api/nodes.py | |||
2 | index 878c418..f228e1a 100644 | |||
3 | --- a/src/maasserver/api/nodes.py | |||
4 | +++ b/src/maasserver/api/nodes.py | |||
5 | @@ -930,7 +930,7 @@ class PowerMixin: | |||
6 | 930 | system_id=system_id, user=request.user, | 930 | system_id=system_id, user=request.user, |
7 | 931 | perm=NodePermission.view) | 931 | perm=NodePermission.view) |
8 | 932 | return { | 932 | return { |
10 | 933 | "state": node.power_query().wait(45), | 933 | "state": node.power_query().wait(60), |
11 | 934 | } | 934 | } |
12 | 935 | 935 | ||
13 | 936 | @operation(idempotent=False) | 936 | @operation(idempotent=False) |
14 | diff --git a/src/maasserver/clusterrpc/power.py b/src/maasserver/clusterrpc/power.py | |||
15 | index c117386..7eafab6 100644 | |||
16 | --- a/src/maasserver/clusterrpc/power.py | |||
17 | +++ b/src/maasserver/clusterrpc/power.py | |||
18 | @@ -215,7 +215,7 @@ def pick_best_power_state(power_states): | |||
19 | 215 | 215 | ||
20 | 216 | 216 | ||
21 | 217 | @asynchronous(timeout=FOREVER) | 217 | @asynchronous(timeout=FOREVER) |
23 | 218 | def power_query_all(system_id, hostname, power_info, timeout=30): | 218 | def power_query_all(system_id, hostname, power_info, timeout=60): |
24 | 219 | """Query every connected rack controller and get the power status from all | 219 | """Query every connected rack controller and get the power status from all |
25 | 220 | rack controllers. | 220 | rack controllers. |
26 | 221 | 221 | ||
27 | diff --git a/src/provisioningserver/drivers/power/redfish.py b/src/provisioningserver/drivers/power/redfish.py | |||
28 | index ef37367..1bd31b2 100644 | |||
29 | --- a/src/provisioningserver/drivers/power/redfish.py | |||
30 | +++ b/src/provisioningserver/drivers/power/redfish.py | |||
31 | @@ -34,14 +34,15 @@ from twisted.web.client import ( | |||
32 | 34 | FileBodyProducer, | 34 | FileBodyProducer, |
33 | 35 | PartialDownloadError, | 35 | PartialDownloadError, |
34 | 36 | readBody, | 36 | readBody, |
35 | 37 | RedirectAgent, | ||
36 | 37 | ) | 38 | ) |
37 | 38 | from twisted.web.http_headers import Headers | 39 | from twisted.web.http_headers import Headers |
38 | 39 | 40 | ||
40 | 40 | 41 | # no trailing slashes | |
41 | 41 | REDFISH_POWER_CONTROL_ENDPOINT = ( | 42 | REDFISH_POWER_CONTROL_ENDPOINT = ( |
43 | 42 | b"redfish/v1/Systems/%s/Actions/ComputerSystem.Reset/") | 43 | b"redfish/v1/Systems/%s/Actions/ComputerSystem.Reset") |
44 | 43 | 44 | ||
46 | 44 | REDFISH_SYSTEMS_ENDPOINT = b"redfish/v1/Systems/" | 45 | REDFISH_SYSTEMS_ENDPOINT = b"redfish/v1/Systems" |
47 | 45 | 46 | ||
48 | 46 | 47 | ||
49 | 47 | class RedfishPowerDriverBase(PowerDriver): | 48 | class RedfishPowerDriverBase(PowerDriver): |
50 | @@ -61,6 +62,7 @@ class RedfishPowerDriverBase(PowerDriver): | |||
51 | 61 | return Headers( | 62 | return Headers( |
52 | 62 | { | 63 | { |
53 | 63 | b"User-Agent": [b"MAAS"], | 64 | b"User-Agent": [b"MAAS"], |
54 | 65 | b"Accept": [b"application/json"], | ||
55 | 64 | b"Authorization": [b"Basic " + authorization], | 66 | b"Authorization": [b"Basic " + authorization], |
56 | 65 | b"Content-Type": [b"application/json; charset=utf-8"], | 67 | b"Content-Type": [b"application/json; charset=utf-8"], |
57 | 66 | } | 68 | } |
58 | @@ -69,7 +71,8 @@ class RedfishPowerDriverBase(PowerDriver): | |||
59 | 69 | @asynchronous | 71 | @asynchronous |
60 | 70 | def redfish_request(self, method, uri, headers=None, bodyProducer=None): | 72 | def redfish_request(self, method, uri, headers=None, bodyProducer=None): |
61 | 71 | """Send the redfish request and return the response.""" | 73 | """Send the redfish request and return the response.""" |
63 | 72 | agent = Agent(reactor, contextFactory=WebClientContextFactory()) | 74 | agent = RedirectAgent( |
64 | 75 | Agent(reactor, contextFactory=WebClientContextFactory())) | ||
65 | 73 | d = agent.request( | 76 | d = agent.request( |
66 | 74 | method, uri, headers=headers, bodyProducer=bodyProducer) | 77 | method, uri, headers=headers, bodyProducer=bodyProducer) |
67 | 75 | 78 | ||
68 | @@ -90,16 +93,33 @@ class RedfishPowerDriverBase(PowerDriver): | |||
69 | 90 | data = data.decode('utf-8') | 93 | data = data.decode('utf-8') |
70 | 91 | # Only decode non-empty response bodies. | 94 | # Only decode non-empty response bodies. |
71 | 92 | if data: | 95 | if data: |
73 | 93 | return json.loads(data) | 96 | # occasionally invalid json is returned. provide a clear |
74 | 97 | # error in that case | ||
75 | 98 | try: | ||
76 | 99 | return json.loads(data) | ||
77 | 100 | except ValueError as error: | ||
78 | 101 | raise PowerActionError( | ||
79 | 102 | "Redfish request failed from a JSON parse error:" | ||
80 | 103 | " %s." % error) | ||
81 | 94 | 104 | ||
82 | 95 | def cb_attach_headers(data, headers): | 105 | def cb_attach_headers(data, headers): |
83 | 96 | return data, headers | 106 | return data, headers |
84 | 97 | 107 | ||
85 | 98 | # Error out if the response has a status code of 400 or above. | 108 | # Error out if the response has a status code of 400 or above. |
86 | 99 | if response.code >= int(HTTPStatus.BAD_REQUEST): | 109 | if response.code >= int(HTTPStatus.BAD_REQUEST): |
90 | 100 | raise PowerActionError( | 110 | # if there was no trailing slash, retry with a trailing slash |
91 | 101 | "Redfish request failed with response status code:" | 111 | # because of varying requirements of BMC manufacturers |
92 | 102 | " %s." % response.code) | 112 | if (response.code == HTTPStatus.NOT_FOUND and |
93 | 113 | uri.decode('utf-8')[-1] != "/"): | ||
94 | 114 | d = agent.request( | ||
95 | 115 | method, | ||
96 | 116 | uri + "/".encode('utf-8'), | ||
97 | 117 | headers=headers, | ||
98 | 118 | bodyProducer=bodyProducer) | ||
99 | 119 | else: | ||
100 | 120 | raise PowerActionError( | ||
101 | 121 | "Redfish request failed with response status code:" | ||
102 | 122 | " %s." % response.code) | ||
103 | 103 | 123 | ||
104 | 104 | d = readBody(response) | 124 | d = readBody(response) |
105 | 105 | d.addErrback(eb_catch_partial) | 125 | d.addErrback(eb_catch_partial) |
106 | @@ -165,7 +185,7 @@ class RedfishPowerDriver(RedfishPowerDriverBase): | |||
107 | 165 | @inlineCallbacks | 185 | @inlineCallbacks |
108 | 166 | def set_pxe_boot(self, url, node_id, headers): | 186 | def set_pxe_boot(self, url, node_id, headers): |
109 | 167 | """Set the machine with node_id to PXE boot.""" | 187 | """Set the machine with node_id to PXE boot.""" |
111 | 168 | endpoint = REDFISH_SYSTEMS_ENDPOINT + b'%s/' % node_id | 188 | endpoint = join(REDFISH_SYSTEMS_ENDPOINT, b'%s' % node_id) |
112 | 169 | payload = FileBodyProducer( | 189 | payload = FileBodyProducer( |
113 | 170 | BytesIO( | 190 | BytesIO( |
114 | 171 | json.dumps( | 191 | json.dumps( |
115 | @@ -213,14 +233,16 @@ class RedfishPowerDriver(RedfishPowerDriverBase): | |||
116 | 213 | url, node_id, headers = yield self.process_redfish_context(context) | 233 | url, node_id, headers = yield self.process_redfish_context(context) |
117 | 214 | # Set to PXE boot. | 234 | # Set to PXE boot. |
118 | 215 | yield self.set_pxe_boot(url, node_id, headers) | 235 | yield self.set_pxe_boot(url, node_id, headers) |
121 | 216 | # Power off the machine. | 236 | # Power off the machine if it is not already off |
122 | 217 | yield self.power("ForceOff", url, node_id, headers) | 237 | power_state = yield self.power_query(node_id, context) |
123 | 238 | if power_state != 'off': | ||
124 | 239 | yield self.power("ForceOff", url, node_id, headers) | ||
125 | 218 | 240 | ||
126 | 219 | @asynchronous | 241 | @asynchronous |
127 | 220 | @inlineCallbacks | 242 | @inlineCallbacks |
128 | 221 | def power_query(self, node_id, context): | 243 | def power_query(self, node_id, context): |
129 | 222 | """Power query machine.""" | 244 | """Power query machine.""" |
130 | 223 | url, node_id, headers = yield self.process_redfish_context(context) | 245 | url, node_id, headers = yield self.process_redfish_context(context) |
132 | 224 | uri = join(url, REDFISH_SYSTEMS_ENDPOINT + b'%s/' % node_id) | 246 | uri = join(url, REDFISH_SYSTEMS_ENDPOINT, b'%s' % node_id) |
133 | 225 | node_data, _ = yield self.redfish_request(b"GET", uri, headers) | 247 | node_data, _ = yield self.redfish_request(b"GET", uri, headers) |
134 | 226 | return node_data.get('PowerState').lower() | 248 | return node_data.get('PowerState').lower() |
135 | diff --git a/src/provisioningserver/drivers/power/tests/test_redfish.py b/src/provisioningserver/drivers/power/tests/test_redfish.py | |||
136 | index ca2a771..a937190 100644 | |||
137 | --- a/src/provisioningserver/drivers/power/tests/test_redfish.py | |||
138 | +++ b/src/provisioningserver/drivers/power/tests/test_redfish.py | |||
139 | @@ -224,6 +224,7 @@ class TestRedfishPowerDriver(MAASTestCase): | |||
140 | 224 | authorization = b64encode(creds.encode('utf-8')) | 224 | authorization = b64encode(creds.encode('utf-8')) |
141 | 225 | attributes = { | 225 | attributes = { |
142 | 226 | b"User-Agent": [b"MAAS"], | 226 | b"User-Agent": [b"MAAS"], |
143 | 227 | b"Accept": [b"application/json"], | ||
144 | 227 | b"Authorization": [b"Basic " + authorization], | 228 | b"Authorization": [b"Basic " + authorization], |
145 | 228 | b"Content-Type": [b"application/json; charset=utf-8"], | 229 | b"Content-Type": [b"application/json; charset=utf-8"], |
146 | 229 | } | 230 | } |
147 | @@ -255,6 +256,60 @@ class TestRedfishPowerDriver(MAASTestCase): | |||
148 | 255 | self.assertEquals(expected_headers.headers, headers) | 256 | self.assertEquals(expected_headers.headers, headers) |
149 | 256 | 257 | ||
150 | 257 | @inlineCallbacks | 258 | @inlineCallbacks |
151 | 259 | def test_wrap_redfish_request_retries_404s_trailing_slash(self): | ||
152 | 260 | driver = RedfishPowerDriver() | ||
153 | 261 | context = make_context() | ||
154 | 262 | url = driver.get_url(context) | ||
155 | 263 | uri = join(url, b"redfish/v1/Systems") | ||
156 | 264 | headers = driver.make_auth_headers(**context) | ||
157 | 265 | mock_agent = self.patch(redfish_module, 'Agent') | ||
158 | 266 | mock_agent.return_value.request = Mock() | ||
159 | 267 | expected_headers = Mock() | ||
160 | 268 | expected_headers.code = HTTPStatus.NOT_FOUND | ||
161 | 269 | expected_headers.headers = "Testing Headers" | ||
162 | 270 | happy_headers = Mock() | ||
163 | 271 | happy_headers.code = HTTPStatus.OK | ||
164 | 272 | happy_headers.headers = "Testing Headers" | ||
165 | 273 | mock_agent.return_value.request.side_effect = [ | ||
166 | 274 | succeed(expected_headers), | ||
167 | 275 | succeed(happy_headers) | ||
168 | 276 | ] | ||
169 | 277 | mock_readBody = self.patch(redfish_module, 'readBody') | ||
170 | 278 | mock_readBody.return_value = succeed( | ||
171 | 279 | json.dumps(SAMPLE_JSON_SYSTEMS).encode('utf-8')) | ||
172 | 280 | expected_response = SAMPLE_JSON_SYSTEMS | ||
173 | 281 | |||
174 | 282 | response, return_headers = yield driver.redfish_request( | ||
175 | 283 | b"GET", uri, headers) | ||
176 | 284 | self.assertThat( | ||
177 | 285 | mock_agent.return_value.request, | ||
178 | 286 | MockCallsMatch( | ||
179 | 287 | call(b"GET", uri, headers, None), | ||
180 | 288 | call(b"GET", uri + "/".encode('utf-8'), headers, None))) | ||
181 | 289 | self.assertEquals(expected_response, response) | ||
182 | 290 | self.assertEquals(expected_headers.headers, return_headers) | ||
183 | 291 | |||
184 | 292 | @inlineCallbacks | ||
185 | 293 | def test_redfish_request_raises_invalid_json_error(self): | ||
186 | 294 | driver = RedfishPowerDriver() | ||
187 | 295 | context = make_context() | ||
188 | 296 | url = driver.get_url(context) | ||
189 | 297 | uri = join(url, b"redfish/v1/Systems") | ||
190 | 298 | headers = driver.make_auth_headers(**context) | ||
191 | 299 | mock_agent = self.patch(redfish_module, 'Agent') | ||
192 | 300 | mock_agent.return_value.request = Mock() | ||
193 | 301 | expected_headers = Mock() | ||
194 | 302 | expected_headers.code = HTTPStatus.OK | ||
195 | 303 | expected_headers.headers = "Testing Headers" | ||
196 | 304 | mock_agent.return_value.request.return_value = succeed( | ||
197 | 305 | expected_headers) | ||
198 | 306 | mock_readBody = self.patch(redfish_module, 'readBody') | ||
199 | 307 | mock_readBody.return_value = succeed( | ||
200 | 308 | '{"invalid": "json"'.encode('utf-8')) | ||
201 | 309 | with ExpectedException(PowerActionError): | ||
202 | 310 | yield driver.redfish_request(b"GET", uri, headers) | ||
203 | 311 | |||
204 | 312 | @inlineCallbacks | ||
205 | 258 | def test_redfish_request_continues_partial_download_error(self): | 313 | def test_redfish_request_continues_partial_download_error(self): |
206 | 259 | driver = RedfishPowerDriver() | 314 | driver = RedfishPowerDriver() |
207 | 260 | context = make_context() | 315 | context = make_context() |
208 | @@ -371,7 +426,7 @@ class TestRedfishPowerDriver(MAASTestCase): | |||
209 | 371 | 426 | ||
210 | 372 | yield driver.set_pxe_boot(url, node_id, headers) | 427 | yield driver.set_pxe_boot(url, node_id, headers) |
211 | 373 | self.assertThat(mock_redfish_request, MockCalledOnceWith( | 428 | self.assertThat(mock_redfish_request, MockCalledOnceWith( |
213 | 374 | b"PATCH", join(url, b"redfish/v1/Systems/%s/" % node_id), | 429 | b"PATCH", join(url, b"redfish/v1/Systems/%s" % node_id), |
214 | 375 | headers, payload)) | 430 | headers, payload)) |
215 | 376 | 431 | ||
216 | 377 | @inlineCallbacks | 432 | @inlineCallbacks |
217 | @@ -409,6 +464,8 @@ class TestRedfishPowerDriver(MAASTestCase): | |||
218 | 409 | mock_redfish_request.return_value = ( | 464 | mock_redfish_request.return_value = ( |
219 | 410 | SAMPLE_JSON_SYSTEMS, None) | 465 | SAMPLE_JSON_SYSTEMS, None) |
220 | 411 | mock_set_pxe_boot = self.patch(driver, 'set_pxe_boot') | 466 | mock_set_pxe_boot = self.patch(driver, 'set_pxe_boot') |
221 | 467 | mock_power_query = self.patch(driver, 'power_query') | ||
222 | 468 | mock_power_query.return_value = "on" | ||
223 | 412 | mock_power = self.patch(driver, 'power') | 469 | mock_power = self.patch(driver, 'power') |
224 | 413 | 470 | ||
225 | 414 | yield driver.power_off(node_id, context) | 471 | yield driver.power_off(node_id, context) |
226 | @@ -418,6 +475,26 @@ class TestRedfishPowerDriver(MAASTestCase): | |||
227 | 418 | "ForceOff", url, node_id, headers)) | 475 | "ForceOff", url, node_id, headers)) |
228 | 419 | 476 | ||
229 | 420 | @inlineCallbacks | 477 | @inlineCallbacks |
230 | 478 | def test__power_off_already_off(self): | ||
231 | 479 | driver = RedfishPowerDriver() | ||
232 | 480 | context = make_context() | ||
233 | 481 | url = driver.get_url(context) | ||
234 | 482 | headers = driver.make_auth_headers(**context) | ||
235 | 483 | node_id = b'1' | ||
236 | 484 | mock_redfish_request = self.patch(driver, 'redfish_request') | ||
237 | 485 | mock_redfish_request.return_value = ( | ||
238 | 486 | SAMPLE_JSON_SYSTEMS, None) | ||
239 | 487 | mock_set_pxe_boot = self.patch(driver, 'set_pxe_boot') | ||
240 | 488 | mock_power_query = self.patch(driver, 'power_query') | ||
241 | 489 | mock_power_query.return_value = "off" | ||
242 | 490 | mock_power = self.patch(driver, 'power') | ||
243 | 491 | |||
244 | 492 | yield driver.power_off(node_id, context) | ||
245 | 493 | self.assertThat(mock_set_pxe_boot, MockCalledOnceWith( | ||
246 | 494 | url, node_id, headers)) | ||
247 | 495 | self.assertThat(mock_power, MockNotCalled()) | ||
248 | 496 | |||
249 | 497 | @inlineCallbacks | ||
250 | 421 | def test_power_query_queries_on(self): | 498 | def test_power_query_queries_on(self): |
251 | 422 | driver = RedfishPowerDriver() | 499 | driver = RedfishPowerDriver() |
252 | 423 | power_change = "On" | 500 | power_change = "On" |
UNIT TESTS
-b tgt-redfish-bugfix lp:~fwieffering/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: FAILED maas-ci- jenkins. internal: 8080/job/ maas/job/ branch- tester/ 6476/console 27e6a387aa010f4 89c469dac0
LOG: http://
COMMIT: 18bdccb5235e69f