diff --git a/pyipmi/hpm.py b/pyipmi/hpm.py index 136e5985..8cd69485 100644 --- a/pyipmi/hpm.py +++ b/pyipmi/hpm.py @@ -16,7 +16,6 @@ from __future__ import annotations -import os import codecs import struct import collections @@ -28,7 +27,7 @@ from .errors import CompletionCodeError, HpmError, IpmiTimeoutError from .msgs import create_request_by_name, Message from .msgs import constants -from .utils import check_completion_code, bcd_search, chunks +from .utils import check_completion_code, bcd_search from .utils import py3dec_unic_bytes_fix, py3_array_tobytes from .state import State from .fields import VersionField @@ -41,13 +40,26 @@ PROPERTY_DEFERRED_VERSION = 4 PROPERTY_OEM = list(range(192, 255)) +# actions of the Initiate Upgrade Action command ACTION_BACKUP_COMPONENT = 0x00 ACTION_PREPARE_COMPONENT = 0x01 ACTION_UPLOAD_FOR_UPGRADE = 0x02 ACTION_UPLOAD_FOR_COMPARE = 0x03 +# action types of the upgrade image action records +IMAGE_ACTION_BACKUP_COMPONENTS = 0x00 +IMAGE_ACTION_PREPARE_COMPONENTS = 0x01 +IMAGE_ACTION_UPLOAD_FIRMWARE_IMAGE = 0x02 + CC_LONG_DURATION_CMD_IN_PROGRESS = 0x80 +# an IPMB message is limited to 32 bytes, this leaves 25 bytes request data +IPMB_MAX_REQUEST_DATA_SIZE = 25 +# a bridged message is embedded in a Send Message request on the IPMB +SEND_MESSAGE_OVERHEAD = 8 +# PICMG identifier and block number of the Upload Firmware Block request +UPLOAD_FIRMWARE_BLOCK_HEADER_SIZE = 2 + CC_GET_COMP_PROP_UPGRADE_NOT_SUPPORTED_OVER_INTF = 0x81 CC_GET_COMP_PROP_INVALID_COMPONENT = 0x82 CC_GET_COMP_PROP_INVALID_PROPERTIES_SELECTOR = 0x83 @@ -148,32 +160,68 @@ def upload_firmware_block(self, block_number: int, data: bytes) -> None: self.send_message_with_name('UploadFirmwareBlock', number=block_number, data=data) - @staticmethod - def _determine_max_block_size() -> int: - return 22 + def _determine_max_block_size(self) -> int: + """Return the maximum firmware data length of an upload block. + + A message sent directly by the interface is limited by the + interface, a bridged message by the IPMB message length. Each + additional bridge embeds the message in another Send Message + request on the IPMB. + """ + routing = getattr(self.target, 'routing', None) + bridges = len(routing) - 1 if routing else 0 + if bridges > 0: + size = IPMB_MAX_REQUEST_DATA_SIZE \ + - (bridges - 1) * SEND_MESSAGE_OVERHEAD + else: + size = getattr(self.interface, 'MAX_REQUEST_DATA_SIZE', None) + if not isinstance(size, int): + size = IPMB_MAX_REQUEST_DATA_SIZE + return size - UPLOAD_FIRMWARE_BLOCK_HEADER_SIZE def upload_binary(self, binary: bytes, timeout: float = 2, interval: float = 0.1, retry: int = 3) -> None: - """Upload all firmware blocks from a binary.""" + """Upload all firmware blocks from a binary. + + If the target rejects the length of the first block, the block size + is reduced until a block is accepted. + """ block_number = 0 block_size = self._determine_max_block_size() + block_size_accepted = False + offset = 0 + + while offset < len(binary): + chunk = binary[offset:offset + block_size] + # a timed out block is sent again, up to `retry` times + for attempt in range(retry): + try: + self.upload_firmware_block(block_number, chunk) + except CompletionCodeError as e: + if e.cc == CC_LONG_DURATION_CMD_IN_PROGRESS: + self.wait_for_long_duration_command( + constants.CMDID_HPM_UPLOAD_FIRMWARE_BLOCK, + timeout, interval) + elif (e.cc in (constants.CC_REQ_DATA_INV_LENGTH, + constants.CC_REQ_DATA_FIELD_EXCEED) + and not block_size_accepted and block_size > 1): + block_size -= 1 + chunk = None + else: + raise HpmError('upload_firmware_block CC=0x%02x' + % e.cc) from e + except IpmiTimeoutError: + if attempt == retry - 1: + raise IpmiTimeoutError() from None + continue + break - for chunk in chunks(binary, block_size): - try: - self.upload_firmware_block(block_number, chunk) - except CompletionCodeError as e: - if e.cc == CC_LONG_DURATION_CMD_IN_PROGRESS: - - self.wait_for_long_duration_command( - constants.CMDID_HPM_UPLOAD_FIRMWARE_BLOCK, - timeout, interval) - else: - raise HpmError('upload_firmware_block CC=0x%02x' % e.cc) from e - except IpmiTimeoutError: - retry -= 1 - if retry == 0: - raise IpmiTimeoutError() from None + if chunk is None: + # send the block again with the reduced size + continue + block_size_accepted = True + offset += len(chunk) block_number += 1 block_number &= 0xff @@ -336,12 +384,30 @@ def preparation_stage(self, image: UpgradeImage) -> None: if support is not True: raise HpmError('no supported component in image') - def upgrade_stage(self, image: UpgradeImage, component: int) -> None: + def upgrade_stage(self, image: UpgradeImage, component: int, + compare: bool = False) -> None: + """Perform the action records of the image for the component. + + With `compare` the firmware image is uploaded for comparison with + the active copy of the component, the backup and prepare actions + are skipped. + """ for action in image.actions: if action.components & (1 << component) == 0: continue + if isinstance(action, UpgradeActionRecordUploadForUpgrade): + if compare: + upgrade_action = ACTION_UPLOAD_FOR_COMPARE + else: + upgrade_action = ACTION_UPLOAD_FOR_UPGRADE + elif compare: + continue + elif isinstance(action, UpgradeActionRecordBackup): + upgrade_action = ACTION_BACKUP_COMPONENT + else: + upgrade_action = ACTION_PREPARE_COMPONENT self.initiate_upgrade_action_and_wait(1 << component, - action.action_type) + upgrade_action) if isinstance(action, UpgradeActionRecordUploadForUpgrade): self.upload_binary(action.firmware_image_data) self.finish_upload_and_wait(component, action.firmware_length) @@ -351,15 +417,25 @@ def _activation_state_do_self_testing(self) -> None: def wait_until_new_firmware_comes_up(self, timeout: float, interval: float) -> None: + """Wait until the controller answers again after the activation. + + Only an answer after the controller was not accessible is from the + new firmware, before it may still be the old one. If the controller + does not become inaccessible, wait until the timeout. + """ + was_inaccessible = False start_time = time.time() while time.time() < start_time + timeout: try: self.get_upgrade_status() self.get_device_id() - except IpmiTimeoutError: - time.sleep(interval) - except OSError: + except (IpmiTimeoutError, OSError): + was_inaccessible = True time.sleep(interval) + continue + if was_inaccessible: + break + time.sleep(interval) time.sleep(5) def activation_stage(self, image: UpgradeImage, component: int) -> None: @@ -369,11 +445,17 @@ def activation_stage(self, image: UpgradeImage, component: int) -> None: image.header.inaccessibility_timeout, 1) self._activation_state_do_self_testing() + @staticmethod + def _check_component_in_image(image: UpgradeImage, + component: int) -> None: + if component not in image.header.components: + raise HpmError('component=%d not in image (image components: %s)' + % (component, image.header.components)) + def install_component_from_image(self, image: UpgradeImage, component: int) -> None: + self._check_component_in_image(image, component) self.abort_firmware_upgrade() - if component not in image.header.components: - raise HpmError('component=%d not in image' % component) self.preparation_stage(image) self.upgrade_stage(image, component) self.activation_stage(image, component) @@ -382,6 +464,22 @@ def install_component_from_file(self, filename: str, component: int) -> None: image = UpgradeImage(filename) self.install_component_from_image(image, component) + def compare_component_from_image(self, image: UpgradeImage, + component: int) -> None: + """Compare the firmware of the image with the active copy. + + A mismatch is reported by the Finish Firmware Upload command. + """ + self._check_component_in_image(image, component) + self.abort_firmware_upgrade() + self.preparation_stage(image) + self.upgrade_stage(image, component, compare=True) + + def compare_component_from_file(self, filename: str, + component: int) -> None: + image = UpgradeImage(filename) + self.compare_component_from_image(image, component) + class UpgradeStatus(State): @@ -462,7 +560,7 @@ def _from_rsp_data(self, data: bytes) -> None: support.append('reserved') if cap & self.PREPARATION_SUPPORT_MASK: - support.append('prepartion') + support.append('preparation') if cap & self.COMPARISON_SUPPORT_MASK: support.append('comparison') if cap & self.DEFERRED_ACTIVATION_SUPPORT_MASK: @@ -580,8 +678,13 @@ def __init__(self, data: bytes | None = None) -> None: if data: self._from_data(data) + SIGNATURE = b'PICMGFWU' + def _from_data(self, data: bytes) -> None: self.signature = data[0:8] + if self.signature != self.SIGNATURE: + raise HpmError('no HPM.1 upgrade image (invalid signature %s)' + % bytes(self.signature).hex(' ')) for a in self.FORMAT: setattr(self, a.field_name, struct.unpack( @@ -629,32 +732,36 @@ def __str__(self) -> str: class UpgradeActionRecord: ACTIONS = ( - "Backup", - "Prepare", - "Upload for Upgrade", - "Upload for Compare" + "Backup Components", + "Prepare Components", + "Upload Firmware Image", ) + HEADER_LENGTH = 3 + def __init__(self, data: bytes | None = None) -> None: - self.action_type = array('B', data)[0] + self.action_type = None + self.action = None + self.components = None + self.checksum = None + self.length = self.HEADER_LENGTH if data: (self.action, self.components, self.checksum) \ = struct.unpack('BBB', data[0:3]) - self.length = 3 + self.action_type = self.action @staticmethod def create_from_data(data: bytes) -> UpgradeActionRecord: action_type = array('B', data)[0] - if action_type == ACTION_BACKUP_COMPONENT: + if action_type == IMAGE_ACTION_BACKUP_COMPONENTS: return UpgradeActionRecordBackup(data) - elif action_type == ACTION_PREPARE_COMPONENT: + elif action_type == IMAGE_ACTION_PREPARE_COMPONENTS: return UpgradeActionRecordPrepare(data) - elif action_type == ACTION_UPLOAD_FOR_UPGRADE: + elif action_type == IMAGE_ACTION_UPLOAD_FIRMWARE_IMAGE: return UpgradeActionRecordUploadForUpgrade(data) - elif action_type == ACTION_UPLOAD_FOR_COMPARE: - return UpgradeActionRecordUploadForCompare(data) else: - raise HpmError('unsupported ActionRecord') + raise HpmError('unsupported ActionRecord type 0x%02x' + % action_type) def __str__(self) -> str: str = [] @@ -673,21 +780,43 @@ class UpgradeActionRecordPrepare(UpgradeActionRecord): class UpgradeActionRecordUploadForUpgrade(UpgradeActionRecord): + """Upload Firmware Image action record. + + The action header is followed by the firmware version (6 bytes), the + description string (21 bytes), the firmware length (4 bytes) and the + firmware image. The image is uploaded for upgrade or for compare, + this is selected by the Initiate Upgrade Action command. + """ + def __init__(self, data: bytes | None = None) -> None: UpgradeActionRecord.__init__(self, data) + self.firmware_version = None + self.firmware_description_string = None + self.firmware_length = None + self.firmware_image_data = None if data: self.firmware_version = \ VersionField( data[3:3 + VersionField.VERSION_WITH_AUX_FIELD_LEN]) + # strip the '\x00' padding self.firmware_description_string \ - = py3dec_unic_bytes_fix(data[9:30]) + = py3dec_unic_bytes_fix(data[9:30]).rstrip('\0') self.firmware_length = struct.unpack(' str: + str = [UpgradeActionRecord.__str__(self)] + str.append(" Firmware Version: %s" % self.firmware_version) + str.append(" Description: %s" + % self.firmware_description_string) + str.append(" Firmware Length: %s" % self.firmware_length) + return "\n".join(str) class ImageChecksumRecord: @@ -714,33 +843,27 @@ def __str__(self) -> str: return "\n".join(str) def _check_md5_sum(self, filedata: bytes) -> None: - summer = hashlib.md5() - self.checksum_actual \ - = summer.update(filedata[:-HPM_IMAGE_CHECKSUM_SIZE]) + self.checksum_actual = hashlib.md5( + filedata[:-HPM_IMAGE_CHECKSUM_SIZE]).digest() self.checksum_expected = filedata[-HPM_IMAGE_CHECKSUM_SIZE:] + if self.checksum_actual != self.checksum_expected: + raise HpmError('image MD5 checksum mismatch') def _from_file(self, filename: str) -> None: - try: - file = open(filename, "rb") - except OSError: - print('Error open file "%s"' % filename) - - ################################ - # get file size - file_size = os.stat(filename).st_size - file_data = file.read(file_size) - - ################################ - # get image checksum - self._check_md5_sum(file_data) - # XXX verify checksum + with open(filename, "rb") as file: + file_data = file.read() + file_size = len(file_data) ################################ # Upgrade Image Header self.header = UpgradeImageHeaderRecord(file_data) off = self.header.length + ################################ + # verify image checksum + self._check_md5_sum(file_data) + ################################ # Upgrade Actions self.actions = [] @@ -752,5 +875,3 @@ def _from_file(self, filename: str) -> None: ################################ # Image checksum self.checksum = ImageChecksumRecord(file_data[off:file_size]) - - file.close() diff --git a/pyipmi/interfaces/base.py b/pyipmi/interfaces/base.py index 8b6a6f3b..daecebb2 100644 --- a/pyipmi/interfaces/base.py +++ b/pyipmi/interfaces/base.py @@ -34,6 +34,10 @@ class Interface: NAME: str | None = None + # maximum request data length of a message sent directly (not bridged) + # by the interface, None for the IPMB default + MAX_REQUEST_DATA_SIZE: int | None = None + def open(self) -> None: pass diff --git a/pyipmi/interfaces/rmcp.py b/pyipmi/interfaces/rmcp.py index 18c0c944..616eae87 100644 --- a/pyipmi/interfaces/rmcp.py +++ b/pyipmi/interfaces/rmcp.py @@ -366,6 +366,8 @@ def check_header(self) -> None: class Rmcp(Interface): NAME = 'rmcp' + # 45 bytes LAN message length minus 7 bytes message header and checksums + MAX_REQUEST_DATA_SIZE = 38 _session: Session | None = None diff --git a/pyipmi/ipmitool.py b/pyipmi/ipmitool.py index 3ff0dae0..631dc0f9 100755 --- a/pyipmi/ipmitool.py +++ b/pyipmi/ipmitool.py @@ -346,7 +346,7 @@ def cmd_hpm_capabilities(ipmi: pyipmi.Ipmi, args: argparse.Namespace) -> None: def cmd_hpm_check_file(ipmi: pyipmi.Ipmi, args: argparse.Namespace) -> None: - cap = ipmi.open_upgrade_image(args.file) + cap = pyipmi.hpm.UpgradeImage(args.file) print(cap.header) for action in cap.actions: @@ -1279,6 +1279,11 @@ def main(argv: list[str] | None = None) -> None: if args.verbose: traceback.print_exc() sys.exit(1) + except pyipmi.errors.HpmError as e: + print('HPM error: %s' % e) + if args.verbose: + traceback.print_exc() + sys.exit(1) except KeyboardInterrupt: if args.verbose: traceback.print_exc() diff --git a/pyipmi/msgs/hpm.py b/pyipmi/msgs/hpm.py index 5f81de51..7b4d93a8 100644 --- a/pyipmi/msgs/hpm.py +++ b/pyipmi/msgs/hpm.py @@ -43,14 +43,14 @@ class GetTargetUpgradeCapabilitiesRsp(PicmgMessage): GroupExtensionIdentifier('picmg_identifier', PICMG_IDENTIFIER), UnsignedInt('hpm_1_version', 1), Bitfield('capabilities', 1, - Bitfield.Bit('firmware_upgrade_undesirable', 1), - Bitfield.Bit('automatic_rollback_overriden', 1), - Bitfield.Bit('ipmc_degraded_during_upgrade', 1), - Bitfield.Bit('deferred_activation', 1), - Bitfield.Bit('services_affected_by_upgrade', 1), - Bitfield.Bit('manual_rollback', 1), + Bitfield.Bit('selftest', 1), Bitfield.Bit('automatic_rollback', 1), - Bitfield.Bit('selftest', 1),), + Bitfield.Bit('manual_rollback', 1), + Bitfield.Bit('services_affected_by_upgrade', 1), + Bitfield.Bit('deferred_activation', 1), + Bitfield.Bit('ipmc_degraded_during_upgrade', 1), + Bitfield.Bit('automatic_rollback_overriden', 1), + Bitfield.Bit('firmware_upgrade_undesirable', 1),), Bitfield('timeout', 4, Bitfield.Bit('upgrade', 8), Bitfield.Bit('selftest', 8), diff --git a/tests/msgs/test_hpm.py b/tests/msgs/test_hpm.py index d34f06ec..56092b26 100644 --- a/tests/msgs/test_hpm.py +++ b/tests/msgs/test_hpm.py @@ -42,3 +42,22 @@ def test_activatefirmwarereq_encode_valid_req_wo_optional(): m.rollback_override_policy = None data = encode_message(m) assert data == b'\x00' + + +def test_gettargetupgradecapabilitiesrsp_decode_capabilities(): + m = pyipmi.msgs.hpm.GetTargetUpgradeCapabilitiesRsp() + # self-test, automatic/manual rollback and deferred activation + decode_message(m, b'\x00\x00\x00\x17\x0c\x0c\x0c\x0c\x03') + caps = m.capabilities + assert caps.selftest == 1 + assert caps.automatic_rollback == 1 + assert caps.manual_rollback == 1 + assert caps.services_affected_by_upgrade == 0 + assert caps.deferred_activation == 1 + assert caps.ipmc_degraded_during_upgrade == 0 + assert caps.automatic_rollback_overriden == 0 + assert caps.firmware_upgrade_undesirable == 0 + + decode_message(m, b'\x00\x00\x00\x80\x0c\x0c\x0c\x0c\x03') + assert m.capabilities.firmware_upgrade_undesirable == 1 + assert m.capabilities.selftest == 0 diff --git a/tests/test_hpm.py b/tests/test_hpm.py index b085f143..dcaa9ec3 100644 --- a/tests/test_hpm.py +++ b/tests/test_hpm.py @@ -1,9 +1,12 @@ #!/usr/bin/env python +import hashlib import os +import struct import pytest +import pyipmi from pyipmi.errors import HpmError, IpmiTimeoutError from pyipmi.hpm import (Hpm, ComponentProperty, ComponentPropertyDescriptionString, @@ -14,12 +17,13 @@ ComponentPropertyRollbackVersion, UpgradeActionRecord, UpgradeActionRecordBackup, UpgradeActionRecordPrepare, - UpgradeActionRecordUploadForUpgrade, - UpgradeActionRecordUploadForCompare, UpgradeImage, + UpgradeActionRecordUploadForUpgrade, UpgradeImage, + UpgradeImageHeaderRecord, PROPERTY_GENERAL_PROPERTIES, PROPERTY_CURRENT_VERSION, PROPERTY_DESCRIPTION_STRING, PROPERTY_ROLLBACK_VERSION, PROPERTY_DEFERRED_VERSION, PROPERTY_OEM, - ACTION_PREPARE_COMPONENT, ACTION_UPLOAD_FOR_UPGRADE) + ACTION_BACKUP_COMPONENT, ACTION_PREPARE_COMPONENT, + ACTION_UPLOAD_FOR_UPGRADE, ACTION_UPLOAD_FOR_COMPARE) from .ipmi_helper import create_ipmi @@ -70,7 +74,7 @@ def test_deferredversion(self): def test_str(self): prop = ComponentProperty().from_data(PROPERTY_GENERAL_PROPERTIES, b'\x15') - assert str(prop) == ('General: rollback_is_supported, prepartion, ' + assert str(prop) == ('General: rollback_is_supported, preparation, ' 'deferred_activation') prop = ComponentProperty().from_data(PROPERTY_CURRENT_VERSION, @@ -113,9 +117,126 @@ def test_upgradeactionrecord_create_from_data(): assert record.firmware_description_string == '012345678901234567890' assert record.firmware_length == 4 - record = UpgradeActionRecord.create_from_data(b'\x03\x08\x02') - assert record.action == 3 - assert type(record) is UpgradeActionRecordUploadForCompare + record = UpgradeActionRecord.create_from_data( + _upload_record(b'\x11\x22\x33')) + assert type(record) is UpgradeActionRecordUploadForUpgrade + assert record.firmware_version.version_to_string() == '1.2' + assert record.firmware_description_string == 'firmware' + assert record.firmware_length == 3 + assert record.firmware_image_data == b'\x11\x22\x33' + assert record.length == 3 + 31 + 3 + + +def test_upgradeactionrecord_reserved_type(): + # compare is no action record type, only an Initiate Upgrade Action + with pytest.raises(HpmError, match='unsupported ActionRecord type 0x03'): + UpgradeActionRecord.create_from_data(b'\x03\x02\xfb') + + +def _upload_record(image, components=0x02): + return (bytes((0x02, components, 0)) + + b'\x01\x02\x00\x00\x00\x00' + + b'firmware'.ljust(21, b'\x00') + + struct.pack('