Skip to content

Commit 5b0ff7f

Browse files
authored
[BMC] Wait for Redfish readiness after BMC firmware update (#707)
What: Adds RedfishClient.wait_until_redfish_ready() and a thin BMCBase delegator, and reworks bmc_fw_update.py to gate post-reset completion on a quiet Redfish login instead of a ping loop; login() gains a keyword-only log_errors flag. Why: After a BMC firmware update the BMC restarts, and L3/ping recovers before the Redfish management plane, so the old ping-based wait could report completion while the Redfish version API was still unavailable, causing follow-on tooling (e.g. fwutil show status) to race the restart and log transient errors. How: The readiness poll drops any cached session, polls login() until ready or timeout (min(interval, remaining) sleeps, fail-fast only on local permanent errors, AUTH_FAILURE treated as transient), and logs expected reboot-window failures at notice level; the ping loop is removed since a successful login implies L3 is back. Testing: Unit tests in tests/redfish_client_test.py, tests/bmc_base_test.py, and tests/bmc_fw_update_test.py cover readiness paths, delegation, quiet-vs-error logging, and the update flow; all 6 CI checks pass. Signed-off-by: William Tsai <willtsai@nvidia.com>
1 parent 4c9d6b6 commit 5b0ff7f

6 files changed

Lines changed: 305 additions & 63 deletions

File tree

sonic_platform_base/bmc_base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ def _logout(self):
158158
return self.rf_client.logout()
159159
return RedfishClient.ERR_CODE_OK
160160

161+
def wait_until_redfish_ready(self, timeout=RedfishClient.READY_POLL_TIMEOUT,
162+
interval=RedfishClient.READY_POLL_INTERVAL):
163+
"""
164+
Wait until the BMC's Redfish service accepts a login again (e.g. after a
165+
restart); thin pass-through to RedfishClient. Returns a RedfishClient
166+
return code (ERR_CODE_OK when ready).
167+
168+
NOT decorated with @with_session_management -- its _login() would fail
169+
while the BMC is rebooting and defeat the wait.
170+
"""
171+
return self.rf_client.wait_until_redfish_ready(timeout, interval)
172+
161173
def open_session(self):
162174
"""
163175
Open a session with the BMC via the NOS BMC account credentials.

sonic_platform_base/bmc_fw_update.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
import sys
99
import time
1010

11+
# request_bmc_reset() returns before the BMC drops; wait for it to go down first
12+
# so we don't read the still-up pre-reset BMC as ready.
13+
BMC_RESET_SETTLE_TIME = 20 # seconds
14+
1115
def main():
1216
try:
1317
import sonic_platform
@@ -41,21 +45,14 @@ def main():
4145
logger.log_error(f'Failed to restart BMC. Error {ret}: {error_msg}')
4246
sys.exit(1)
4347

44-
# Wait for BMC to restart itself
45-
time.sleep(20)
46-
# Wait for BMC to become operational
47-
max_retries = 5
48-
bmc_is_up = False
49-
for retry in range(max_retries):
50-
bmc_is_up = bmc.get_status()
51-
if bmc_is_up:
52-
break
53-
if retry < max_retries - 1:
54-
logger.log_notice("Waiting for BMC to restart...")
55-
time.sleep(20)
48+
# Let the BMC drop before polling (see BMC_RESET_SETTLE_TIME).
49+
time.sleep(BMC_RESET_SETTLE_TIME)
5650

57-
if not bmc_is_up:
58-
logger.log_error("BMC did not become operational after restart")
51+
# Redfish accepting a login implies L3 is back too -- no ping loop needed.
52+
code = bmc.wait_until_redfish_ready()
53+
if code != 0:
54+
logger.log_error(f"BMC Redfish service did not become ready after "
55+
f"restart (last error code: {code})")
5956
sys.exit(1)
6057

6158
logger.log_notice("BMC firmware update completed successfully")

sonic_platform_base/redfish_client.py

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ class RedfishClient:
5454
ERR_CODE_GENERIC_ERROR = -12
5555
ERR_CODE_IDENTICAL_VERSION = -13
5656

57+
# Readiness-poll defaults for wait_until_redfish_ready() (after a BMC restart).
58+
READY_POLL_TIMEOUT = 300 # seconds, hard upper bound on the readiness wait
59+
READY_POLL_INTERVAL = 10 # seconds between readiness probes
60+
5761
CURL_ERR_OK = 0
5862
CURL_ERR_OPERATION_TIMEDOUT = 28
5963
CURL_ERR_COULDNT_RESOLVE_HOST = 6
@@ -1014,28 +1018,33 @@ def has_login(self):
10141018
'''
10151019
Login Redfish server and get bearer token
10161020
1021+
Args:
1022+
log_errors: When False, log failures at notice not error (readiness poll).
1023+
10171024
Returns:
10181025
An integer, return code
10191026
'''
1020-
def login(self):
1027+
def login(self, *, log_errors=True):
10211028
if self.has_login():
10221029
return RedfishClient.ERR_CODE_OK
10231030

1031+
log_fn = logger.log_error if log_errors else logger.log_notice
1032+
10241033
try:
10251034
password = self.__password_callback()
10261035
except Exception as e:
1027-
logger.log_error(f'{str(e)}')
1036+
log_fn(f'{str(e)}')
10281037
return RedfishClient.ERR_CODE_PASSWORD_UNAVAILABLE
10291038

10301039
cmd = self.__build_login_cmd(password)
10311040
ret, _, response, error = self.exec_curl_cmd(cmd)
10321041

10331042
if (ret != 0):
1034-
logger.log_error(f'Login failure: code {ret}, {error}')
1043+
log_fn(f'Login failure: code {ret}, {error}')
10351044
return ret
10361045

10371046
if response is None or len(response) == 0:
1038-
logger.log_error('Got empty Redfish login response')
1047+
log_fn('Got empty Redfish login response')
10391048
return RedfishClient.ERR_CODE_UNEXPECTED_RESPONSE
10401049

10411050
try:
@@ -1046,29 +1055,29 @@ def login(self):
10461055
json_response = json.loads(body)
10471056
if 'error' in json_response:
10481057
error_msg = json_response['error']['message']
1049-
logger.log_error(f'Login failure: {error_msg}')
1058+
log_fn(f'Login failure: {error_msg}')
10501059
self.log_multi_line_str(response)
10511060
return RedfishClient.ERR_CODE_GENERIC_ERROR
10521061
except Exception as e:
1053-
logger.log_error(f'Login failure: Exception during parsing body: {str(e)}')
1062+
log_fn(f'Login failure: Exception during parsing body: {str(e)}')
10541063
self.log_multi_line_str(response)
10551064

10561065
# Extract both token and session ID from headers
10571066
token = headers.get('x-auth-token')
10581067
if not token:
1059-
logger.log_error('Login failure: no "X-Auth-Token" header found')
1068+
log_fn('Login failure: no "X-Auth-Token" header found')
10601069
self.log_multi_line_str(response)
10611070
return RedfishClient.ERR_CODE_UNEXPECTED_RESPONSE
10621071

10631072
location = headers.get('location')
10641073
if not location:
1065-
logger.log_error('Login failure: no "Location" header found')
1074+
log_fn('Login failure: no "Location" header found')
10661075
self.log_multi_line_str(response)
10671076
return RedfishClient.ERR_CODE_UNEXPECTED_RESPONSE
10681077

10691078
session_id = location.split('/')[-1]
10701079
if not session_id:
1071-
logger.log_error('Login failure: could not extract session ID from Location header')
1080+
log_fn('Login failure: could not extract session ID from Location header')
10721081
self.log_multi_line_str(response)
10731082
return RedfishClient.ERR_CODE_UNEXPECTED_RESPONSE
10741083

@@ -1078,7 +1087,7 @@ def login(self):
10781087
return RedfishClient.ERR_CODE_OK
10791088

10801089
except Exception as e:
1081-
logger.log_error(f'Login failure: exception {str(e)}')
1090+
log_fn(f'Login failure: exception {str(e)}')
10821091
self.log_multi_line_str(response)
10831092
return RedfishClient.ERR_CODE_UNEXPECTED_RESPONSE
10841093

@@ -1141,6 +1150,46 @@ def logout(self, session_id=None):
11411150
logger.log_error('Logout failed: Empty response')
11421151
return RedfishClient.ERR_CODE_UNEXPECTED_RESPONSE
11431152

1153+
'''
1154+
Poll a quiet login() until the Redfish service accepts it or the timeout
1155+
elapses. Used after a BMC restart; a successful login is a stronger "ready"
1156+
signal than ping / L3.
1157+
1158+
Args:
1159+
timeout: Hard upper bound in seconds on the total wait.
1160+
interval: Seconds to sleep between probes.
1161+
1162+
Returns:
1163+
ERR_CODE_OK (0) when ready, else the last non-OK code seen (timeout or a
1164+
non-transient failure).
1165+
'''
1166+
def wait_until_redfish_ready(self, timeout=READY_POLL_TIMEOUT,
1167+
interval=READY_POLL_INTERVAL):
1168+
# Drop any cached session first, or login() short-circuits to OK on the
1169+
# cached token and falsely reports "ready". invalidate_session() is
1170+
# local-only, so (unlike a logout DELETE) it can't log an ERROR.
1171+
self.invalidate_session()
1172+
1173+
deadline = time.time() + timeout
1174+
last_code = None
1175+
while True:
1176+
last_code = self.login(log_errors=False)
1177+
if last_code == RedfishClient.ERR_CODE_OK:
1178+
self.logout()
1179+
logger.log_notice('BMC Redfish service is ready')
1180+
return RedfishClient.ERR_CODE_OK
1181+
1182+
# Fail fast only on a local permanent error. AUTH_FAILURE (401) is
1183+
# transient here: on boot the web server precedes the auth backend.
1184+
if last_code == RedfishClient.ERR_CODE_PASSWORD_UNAVAILABLE:
1185+
return last_code
1186+
1187+
remaining = deadline - time.time()
1188+
if remaining <= 0:
1189+
return last_code
1190+
logger.log_notice('Waiting for BMC Redfish service to become ready...')
1191+
time.sleep(min(interval, remaining))
1192+
11441193
'''
11451194
Get firmware inventory
11461195

tests/bmc_base_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,31 @@ def test_logout_not_logged_in(self, mock_logout, mock_has_login):
166166
assert ret == RedfishClient.ERR_CODE_OK
167167
mock_logout.assert_not_called()
168168

169+
@mock.patch.object(RedfishClient, 'wait_until_redfish_ready')
170+
def test_wait_until_redfish_ready_delegates(self, mock_wait):
171+
"""BMCBase.wait_until_redfish_ready passes args/return through to RedfishClient."""
172+
# Non-zero so the return assertion actually tests pass-through (0 would pass
173+
# even if the delegator ignored the result and returned OK).
174+
mock_wait.return_value = RedfishClient.ERR_CODE_TIMEOUT
175+
176+
bmc = BMCBase('169.254.0.1')
177+
ret = bmc.wait_until_redfish_ready(timeout=123, interval=7)
178+
179+
assert ret == RedfishClient.ERR_CODE_TIMEOUT
180+
mock_wait.assert_called_once_with(123, 7)
181+
182+
@mock.patch.object(RedfishClient, 'wait_until_redfish_ready')
183+
def test_wait_until_redfish_ready_default_args(self, mock_wait):
184+
"""BMCBase.wait_until_redfish_ready defaults come from RedfishClient constants."""
185+
mock_wait.return_value = RedfishClient.ERR_CODE_SERVER_UNREACHABLE
186+
187+
bmc = BMCBase('169.254.0.1')
188+
ret = bmc.wait_until_redfish_ready()
189+
190+
assert ret == RedfishClient.ERR_CODE_SERVER_UNREACHABLE
191+
mock_wait.assert_called_once_with(RedfishClient.READY_POLL_TIMEOUT,
192+
RedfishClient.READY_POLL_INTERVAL)
193+
169194
def test_is_bmc_eeprom_content_valid_empty(self):
170195
"""Test _is_bmc_eeprom_content_valid with empty data"""
171196
bmc = BMCBase('169.254.0.1')

tests/bmc_fw_update_test.py

Lines changed: 18 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def test_main_success_bmc_firmware_updated(self, mock_logger_class, mock_exit, m
4141
mock_bmc.update_firmware.return_value = (0, ('Success', ['BMC_FW_0', 'OTHER_FW']))
4242
mock_bmc.get_firmware_id.return_value = 'BMC_FW_0'
4343
mock_bmc.request_bmc_reset.return_value = (0, 'BMC reset successful')
44-
mock_bmc.get_status.return_value = True
44+
mock_bmc.wait_until_redfish_ready.return_value = 0
4545

4646
test_args = ['bmc_fw_update.py', '/path/to/firmware.bin']
4747
with mock.patch.dict(sys.modules, {'sonic_platform': mock_sonic_platform}):
@@ -53,7 +53,8 @@ def test_main_success_bmc_firmware_updated(self, mock_logger_class, mock_exit, m
5353
mock_bmc.update_firmware.assert_called_once_with('/path/to/firmware.bin')
5454
mock_bmc.get_firmware_id.assert_called_once()
5555
mock_bmc.request_bmc_reset.assert_called_once()
56-
mock_bmc.get_status.assert_called_once()
56+
mock_bmc.wait_until_redfish_ready.assert_called_once()
57+
mock_bmc.get_status.assert_not_called()
5758
mock_sleep.assert_called_once_with(20)
5859
mock_exit.assert_not_called()
5960

@@ -143,9 +144,10 @@ def test_main_update_firmware_failure(self, mock_logger_class, mock_exit):
143144
mock_logger.log_error.assert_called_once_with('Failed to update BMC firmware. Error 1: Update failed')
144145
mock_exit.assert_called_once_with(1)
145146

147+
@mock.patch('sonic_platform_base.bmc_fw_update.time.sleep')
146148
@mock.patch('sys.exit')
147149
@mock.patch('sonic_py_common.logger.Logger')
148-
def test_main_bmc_reset_failure(self, mock_logger_class, mock_exit):
150+
def test_main_bmc_reset_failure(self, mock_logger_class, mock_exit, mock_sleep):
149151
"""Test main when BMC reset fails"""
150152
mock_logger = mock.MagicMock()
151153
mock_bmc = mock.MagicMock()
@@ -159,6 +161,9 @@ def test_main_bmc_reset_failure(self, mock_logger_class, mock_exit):
159161
mock_bmc.update_firmware.return_value = (0, ('Success', ['BMC_FW_0']))
160162
mock_bmc.get_firmware_id.return_value = 'BMC_FW_0'
161163
mock_bmc.request_bmc_reset.return_value = (1, 'Reset failed')
164+
# sys.exit is mocked to a no-op, so main() falls through past the reset
165+
# failure; keep the readiness probe benign so it adds no extra error/exit.
166+
mock_bmc.wait_until_redfish_ready.return_value = 0
162167

163168
test_args = ['bmc_fw_update.py', '/path/to/firmware.bin']
164169
with mock.patch.dict(sys.modules, {'sonic_platform': mock_sonic_platform}):
@@ -171,8 +176,8 @@ def test_main_bmc_reset_failure(self, mock_logger_class, mock_exit):
171176
@mock.patch('sonic_platform_base.bmc_fw_update.time.sleep')
172177
@mock.patch('sys.exit')
173178
@mock.patch('sonic_py_common.logger.Logger')
174-
def test_main_bmc_waiting_after_restart(self, mock_logger_class, mock_exit, mock_sleep):
175-
"""Test waiting loop when BMC is not yet operational after restart"""
179+
def test_main_redfish_not_ready_after_restart(self, mock_logger_class, mock_exit, mock_sleep):
180+
"""Test failure when the Redfish service does not become ready after restart"""
176181
mock_logger = mock.MagicMock()
177182
mock_bmc = mock.MagicMock()
178183
mock_chassis = mock.MagicMock()
@@ -185,44 +190,18 @@ def test_main_bmc_waiting_after_restart(self, mock_logger_class, mock_exit, mock
185190
mock_bmc.update_firmware.return_value = (0, ('Success', ['BMC_FW_0']))
186191
mock_bmc.get_firmware_id.return_value = 'BMC_FW_0'
187192
mock_bmc.request_bmc_reset.return_value = (0, 'BMC reset successful')
188-
mock_bmc.get_status.side_effect = [False, True]
193+
# -6 == ERR_CODE_TIMEOUT: readiness never reached before the deadline.
194+
mock_bmc.wait_until_redfish_ready.return_value = -6
189195

190196
test_args = ['bmc_fw_update.py', '/path/to/firmware.bin']
191197
with mock.patch.dict(sys.modules, {'sonic_platform': mock_sonic_platform}):
192198
with mock.patch.object(sys, 'argv', test_args):
193199
bmc_fw_update.main()
194200

195-
mock_logger.log_notice.assert_any_call("Waiting for BMC to restart...")
196-
assert mock_bmc.get_status.call_count == 2
197-
assert mock_sleep.call_count == 2
198-
mock_sleep.assert_has_calls([mock.call(20), mock.call(20)])
199-
mock_exit.assert_not_called()
200-
201-
@mock.patch('sonic_platform_base.bmc_fw_update.time.sleep')
202-
@mock.patch('sys.exit')
203-
@mock.patch('sonic_py_common.logger.Logger')
204-
def test_main_bmc_not_operational_after_restart(self, mock_logger_class, mock_exit, mock_sleep):
205-
"""Test failure when BMC does not become operational after restart"""
206-
mock_logger = mock.MagicMock()
207-
mock_bmc = mock.MagicMock()
208-
mock_chassis = mock.MagicMock()
209-
mock_platform = mock.MagicMock()
210-
mock_chassis.get_bmc.return_value = mock_bmc
211-
mock_platform.get_chassis.return_value = mock_chassis
212-
mock_sonic_platform = mock.MagicMock()
213-
mock_sonic_platform.platform.Platform.return_value = mock_platform
214-
mock_logger_class.return_value = mock_logger
215-
mock_bmc.update_firmware.return_value = (0, ('Success', ['BMC_FW_0']))
216-
mock_bmc.get_firmware_id.return_value = 'BMC_FW_0'
217-
mock_bmc.request_bmc_reset.return_value = (0, 'BMC reset successful')
218-
mock_bmc.get_status.return_value = False
219-
220-
test_args = ['bmc_fw_update.py', '/path/to/firmware.bin']
221-
with mock.patch.dict(sys.modules, {'sonic_platform': mock_sonic_platform}):
222-
with mock.patch.object(sys, 'argv', test_args):
223-
bmc_fw_update.main()
224-
225-
mock_logger.log_error.assert_any_call("BMC did not become operational after restart")
226-
assert mock_bmc.get_status.call_count == 5
227-
assert mock_sleep.call_count == 5
201+
# Only the settle sleep runs; the ping wait loop is gone.
202+
mock_bmc.get_status.assert_not_called()
203+
mock_bmc.wait_until_redfish_ready.assert_called_once()
204+
mock_sleep.assert_called_once_with(20)
205+
mock_logger.log_error.assert_any_call(
206+
"BMC Redfish service did not become ready after restart (last error code: -6)")
228207
mock_exit.assert_called_once_with(1)

0 commit comments

Comments
 (0)