diff options
-rw-r--r-- | examples/business-logic/example_business_logic.py | 7 | ||||
-rw-r--r-- | ironic_python_agent/agent.py | 11 | ||||
-rw-r--r-- | ironic_python_agent/extensions/image.py | 5 | ||||
-rw-r--r-- | ironic_python_agent/extensions/standby.py | 34 | ||||
-rw-r--r-- | ironic_python_agent/hardware.py | 72 | ||||
-rw-r--r-- | ironic_python_agent/hardware_managers/cna.py | 7 | ||||
-rw-r--r-- | ironic_python_agent/tests/unit/hardware_managers/test_cna.py | 4 | ||||
-rw-r--r-- | ironic_python_agent/tests/unit/test_hardware.py | 63 | ||||
-rw-r--r-- | ironic_python_agent/utils.py | 3 | ||||
-rw-r--r-- | lower-constraints.txt | 26 | ||||
-rw-r--r-- | releasenotes/notes/bmc-mac-introspection-e4c2e203d8529710.yaml | 6 | ||||
-rw-r--r-- | tox.ini | 10 | ||||
-rw-r--r-- | zuul.d/project.yaml | 5 |
13 files changed, 214 insertions, 39 deletions
diff --git a/examples/business-logic/example_business_logic.py b/examples/business-logic/example_business_logic.py index 4fb7fc32..eac88a59 100644 --- a/examples/business-logic/example_business_logic.py +++ b/examples/business-logic/example_business_logic.py @@ -190,5 +190,8 @@ class ExampleBusinessLogicHardwareManager(hardware.HardwareManager): # Make sure to provide default values for optional arguments. def companyx_apply_something(self, node, ports, required_value, optional_value=None): - LOG.info('apply_something called with required_value={} and ' - 'optional_value={}'.format(required_value, optional_value)) + LOG.info('apply_something called with ' + 'required_value=%(required_value)s and ' + 'optional_value=%(optional_value)s', + {'required_value': required_value, + 'optional_value': optional_value}) diff --git a/ironic_python_agent/agent.py b/ironic_python_agent/agent.py index a9f8077f..c5b24560 100644 --- a/ironic_python_agent/agent.py +++ b/ironic_python_agent/agent.py @@ -125,17 +125,16 @@ class IronicPythonAgentHeartbeater(threading.Thread): self.heartbeat_forced = False self.previous_heartbeat = _time() except errors.HeartbeatConflictError: - LOG.warning('conflict error sending heartbeat to {}'.format( - self.agent.api_url)) + LOG.warning('conflict error sending heartbeat to %s', + self.agent.api_url) except Exception: - LOG.exception('error sending heartbeat to {}'.format( - self.agent.api_url)) + LOG.exception('error sending heartbeat to %s', self.agent.api_url) finally: interval_multiplier = random.uniform(self.min_jitter_multiplier, self.max_jitter_multiplier) self.interval = self.agent.heartbeat_timeout * interval_multiplier - log_msg = 'sleeping before next heartbeat, interval: {0}' - LOG.info(log_msg.format(self.interval)) + LOG.info('sleeping before next heartbeat, interval: %s', + self.interval) def force_heartbeat(self): self.heartbeat_forced = True diff --git a/ironic_python_agent/extensions/image.py b/ironic_python_agent/extensions/image.py index b38742b3..1d439923 100644 --- a/ironic_python_agent/extensions/image.py +++ b/ironic_python_agent/extensions/image.py @@ -489,8 +489,9 @@ def _prepare_boot_partitions_for_softraid(device, holders, efi_part, # RAID the ESPs, metadata=1.0 is mandatory to be able to boot md_device = '/dev/md/esp' - LOG.debug("Creating md device {} for the ESPs on {}".format( - md_device, efi_partitions)) + LOG.debug("Creating md device %(md_device)s for the ESPs " + "on %(efi_partitions)s", + {'md_device': md_device, 'efi_partitions': efi_partitions}) utils.execute('mdadm', '--create', md_device, '--force', '--run', '--metadata=1.0', '--level', '1', '--raid-devices', len(efi_partitions), diff --git a/ironic_python_agent/extensions/standby.py b/ironic_python_agent/extensions/standby.py index 970cf7de..e63a9572 100644 --- a/ironic_python_agent/extensions/standby.py +++ b/ironic_python_agent/extensions/standby.py @@ -202,7 +202,7 @@ def _write_whole_disk_image(image, image_info, device): command = ['qemu-img', 'convert', '-t', 'directsync', '-O', 'host_device', '-W', image, device] - LOG.info('Writing image with command: {}'.format(' '.join(command))) + LOG.info('Writing image with command: %s', ' '.join(command)) try: disk_utils.convert_image(image, device, out_format='host_device', cache='directsync', out_of_order=True) @@ -232,8 +232,9 @@ def _write_image(image_info, device, configdrive=None): else: _write_whole_disk_image(image, image_info, device) totaltime = time.time() - starttime - LOG.info('Image {} written to device {} in {} seconds'.format( - image, device, totaltime)) + LOG.info('Image %(image)s written to device %(device)s in %(totaltime)s ' + 'seconds', {'image': image, 'device': device, + 'totaltime': totaltime}) try: disk_utils.fix_gpt_partition(device, node_uuid=None) except exception.InstanceDeployFailure: @@ -329,7 +330,7 @@ class ImageDownload(object): details = [] for url in image_info['urls']: try: - LOG.info("Attempting to download image from {}".format(url)) + LOG.info("Attempting to download image from %s", url) self._request = _download_with_proxy(image_info, url, image_info['id']) except errors.ImageDownloadError as e: @@ -386,12 +387,16 @@ class ImageDownload(object): not match the checksum as reported by glance in image_info. """ checksum = self._hash_algo.hexdigest() - LOG.debug('Verifying image at {} against {} checksum ' - '{}'.format(image_location, self._hash_algo.name, checksum)) + LOG.debug('Verifying image at %(image_location)s against ' + '%(algo_name)s checksum %(checksum)s', + {'image_location': image_location, + 'algo_name': self._hash_algo.name, + 'checksum': checksum}) if checksum != self._expected_hash_value: - LOG.error(errors.ImageChecksumError.details_str.format( + error_msg = errors.ImageChecksumError.details_str.format( image_location, self._image_info['id'], - self._expected_hash_value, checksum)) + self._expected_hash_value, checksum) + LOG.error(error_msg) raise errors.ImageChecksumError(image_location, self._image_info['id'], self._expected_hash_value, @@ -434,8 +439,10 @@ def _download_image(image_info): break totaltime = time.time() - starttime - LOG.info("Image downloaded from {} in {} seconds".format(image_location, - totaltime)) + LOG.info("Image downloaded from %(image_location)s " + "in %(totaltime)s seconds", + {'image_location': image_location, + 'totaltime': totaltime}) image_download.verify_image(image_location) @@ -597,8 +604,8 @@ class StandbyExtension(base.BaseAgentExtension): break totaltime = time.time() - starttime - LOG.info("Image streamed onto device {} in {} " - "seconds".format(device, totaltime)) + LOG.info("Image streamed onto device %(device)s in %(totaltime)s " + "seconds", {'device': device, 'totaltime': totaltime}) # Verify if the checksum of the streamed image is correct image_download.verify_image(device) # Fix any gpt partition @@ -609,7 +616,8 @@ class StandbyExtension(base.BaseAgentExtension): pass # Fix the root partition UUID root_uuid = disk_utils.block_uuid(device) - LOG.info("{} UUID is now {}".format(device, root_uuid)) + LOG.info("%(device)s UUID is now %(root_uuid)s", + {'device': device, 'root_uuid': root_uuid}) self.partition_uuids['root uuid'] = root_uuid def _fix_up_partition_uuids(self, image_info, device): diff --git a/ironic_python_agent/hardware.py b/ironic_python_agent/hardware.py index 04426419..47108405 100644 --- a/ironic_python_agent/hardware.py +++ b/ironic_python_agent/hardware.py @@ -445,13 +445,13 @@ def list_all_block_devices(block_type='disk', # lvm, part, rom, loop if devtype != block_type: if devtype is None or ignore_raid: - LOG.debug("Skipping: {!r}".format(line)) + LOG.debug("Skipping: %s", line) continue elif ('raid' in devtype and block_type in ['raid', 'disk']): LOG.debug( "TYPE detected to contain 'raid', signifying a " - "RAID volume. Found: {!r}".format(line)) + "RAID volume. Found: %s", line) elif (devtype == 'md' and (block_type == 'part' or block_type == 'md')): @@ -461,11 +461,11 @@ def list_all_block_devices(block_type='disk', # more detail. LOG.debug( "TYPE detected to contain 'md', signifying a " - "RAID partition. Found: {!r}".format(line)) + "RAID partition. Found: %s", line) else: LOG.debug( - "TYPE did not match. Wanted: {!r} but found: {!r}".format( - block_type, line)) + "TYPE did not match. Wanted: %(block_type)s but found: " + "%(line)s", {'block_type': block_type, 'line': line}) continue # Ensure all required columns are at least present, even if blank @@ -704,6 +704,9 @@ class HardwareManager(object, metaclass=abc.ABCMeta): def get_bmc_address(self): raise errors.IncompatibleHardwareMethodError() + def get_bmc_mac(self): + raise errors.IncompatibleHardwareMethodError() + def get_bmc_v6address(self): raise errors.IncompatibleHardwareMethodError() @@ -829,6 +832,14 @@ class HardwareManager(object, metaclass=abc.ABCMeta): hardware_info['system_vendor'] = self.get_system_vendor_info() hardware_info['boot'] = self.get_boot_info() hardware_info['hostname'] = netutils.get_hostname() + + try: + hardware_info['bmc_mac'] = self.get_bmc_mac() + except errors.IncompatibleHardwareMethodError: + # if the hardware manager does not support obtaining the BMC MAC, + # we simply don't expose it. + pass + LOG.info('Inventory collected in %.2f second(s)', time.time() - start) return hardware_info @@ -1790,6 +1801,57 @@ class GenericHardwareManager(HardwareManager): return '0.0.0.0' + def get_bmc_mac(self): + """Attempt to detect BMC MAC address + + :return: MAC address of the first LAN channel or 00:00:00:00:00:00 in + case none of them has one or is configured properly + """ + # These modules are rarely loaded automatically + il_utils.try_execute('modprobe', 'ipmi_msghandler') + il_utils.try_execute('modprobe', 'ipmi_devintf') + il_utils.try_execute('modprobe', 'ipmi_si') + + try: + # From all the channels 0-15, only 1-11 can be assigned to + # different types of communication media and protocols and + # effectively used + for channel in range(1, 12): + out, e = utils.execute( + "ipmitool lan print {} | awk '/(IP|MAC) Address[ \\t]*:/" + " {{print $4}}'".format(channel), shell=True) + if e.startswith("Invalid channel"): + continue + + try: + ip, mac = out.strip().split("\n") + except ValueError: + LOG.warning('Invalid ipmitool output %(output)s', + {'output': out}) + continue + + if ip == "0.0.0.0": + # disabled, ignore + continue + + if not re.match("^[0-9a-f]{2}(:[0-9a-f]{2}){5}$", mac, re.I): + LOG.warning('Invalid MAC address %(output)s', + {'output': mac}) + continue + + # In case we get 00:00:00:00:00:00 on a valid channel, we need + # to keep querying + if mac != '00:00:00:00:00:00': + return mac + + except (processutils.ProcessExecutionError, OSError) as e: + # Not error, because it's normal in virtual environment + LOG.warning("Cannot get BMC MAC address: %s", e) + return + + # no valid mac found, signal this clearly + raise errors.IncompatibleHardwareMethodError() + def get_bmc_v6address(self): """Attempt to detect BMC v6 address diff --git a/ironic_python_agent/hardware_managers/cna.py b/ironic_python_agent/hardware_managers/cna.py index 2d49d9e0..a4021c10 100644 --- a/ironic_python_agent/hardware_managers/cna.py +++ b/ironic_python_agent/hardware_managers/cna.py @@ -64,9 +64,10 @@ def _disable_embedded_lldp_agent_in_cna_card(): failed_dirs.append(inner_dir) continue if failed_dirs: - LOG.warning('Failed to disable the embedded LLDP on Intel CNA network ' - 'card. Addresses of failed pci devices: {}' - .format(str(failed_dirs).strip('[]'))) + warning_msg = ('Failed to disable the embedded LLDP on Intel ' + 'CNA network card. Addresses of failed pci ' + 'devices: %s', str(failed_dirs).strip('[]')) + LOG.warning(warning_msg) class IntelCnaHardwareManager(hardware.HardwareManager): diff --git a/ironic_python_agent/tests/unit/hardware_managers/test_cna.py b/ironic_python_agent/tests/unit/hardware_managers/test_cna.py index b38d6d20..29c456a7 100644 --- a/ironic_python_agent/tests/unit/hardware_managers/test_cna.py +++ b/ironic_python_agent/tests/unit/hardware_managers/test_cna.py @@ -112,8 +112,8 @@ class TestIntelCnaHardwareManager(base.IronicAgentTest): cna._disable_embedded_lldp_agent_in_cna_card() expected_log_message = ('Failed to disable the embedded LLDP on ' 'Intel CNA network card. Addresses of ' - 'failed pci devices: {}' - .format(str(listdir_dict).strip('[]'))) + 'failed pci devices: %s', + str(listdir_dict).strip('[]')) mock_log.warning.assert_called_once_with(expected_log_message) @mock.patch.object(cna, 'LOG', autospec=True) diff --git a/ironic_python_agent/tests/unit/test_hardware.py b/ironic_python_agent/tests/unit/test_hardware.py index 61001260..44b2a87d 100644 --- a/ironic_python_agent/tests/unit/test_hardware.py +++ b/ironic_python_agent/tests/unit/test_hardware.py @@ -1165,6 +1165,7 @@ class TestGenericHardwareManager(base.IronicAgentTest): current_boot_mode='bios', pxe_interface='boot:if') self.hardware.get_bmc_address = mock.Mock() + self.hardware.get_bmc_mac = mock.Mock() self.hardware.get_bmc_v6address = mock.Mock() self.hardware.get_system_vendor_info = mock.Mock() @@ -2302,6 +2303,68 @@ class TestGenericHardwareManager(base.IronicAgentTest): @mock.patch.object(il_utils, 'try_execute', autospec=True) @mock.patch.object(utils, 'execute', autospec=True) + def test_get_bmc_mac_not_available(self, mocked_execute, mte): + mocked_execute.return_value = '', '' + self.assertRaises(errors.IncompatibleHardwareMethodError, + self.hardware.get_bmc_mac) + + @mock.patch.object(il_utils, 'try_execute', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + def test_get_bmc_mac(self, mocked_execute, mte): + mocked_execute.return_value = '192.1.2.3\n01:02:03:04:05:06', '' + self.assertEqual('01:02:03:04:05:06', self.hardware.get_bmc_mac()) + + @mock.patch.object(il_utils, 'try_execute', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + def test_get_bmc_mac_virt(self, mocked_execute, mte): + mocked_execute.side_effect = processutils.ProcessExecutionError() + self.assertIsNone(self.hardware.get_bmc_mac()) + + @mock.patch.object(il_utils, 'try_execute', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + def test_get_bmc_mac_zeroed(self, mocked_execute, mte): + mocked_execute.return_value = '0.0.0.0\n00:00:00:00:00:00', '' + self.assertRaises(errors.IncompatibleHardwareMethodError, + self.hardware.get_bmc_mac) + + @mock.patch.object(il_utils, 'try_execute', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + def test_get_bmc_mac_invalid(self, mocked_execute, mte): + # In case of invalid lan channel, stdout is empty and the error + # on stderr is "Invalid channel" + mocked_execute.return_value = '\n', 'Invalid channel: 55' + self.assertRaises(errors.IncompatibleHardwareMethodError, + self.hardware.get_bmc_mac) + + @mock.patch.object(il_utils, 'try_execute', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + def test_get_bmc_mac_random_error(self, mocked_execute, mte): + mocked_execute.return_value = ('192.1.2.3\n00:00:00:00:00:02', + 'Random error message') + self.assertEqual('00:00:00:00:00:02', self.hardware.get_bmc_mac()) + + @mock.patch.object(il_utils, 'try_execute', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) + def test_get_bmc_mac_iterate_channels(self, mocked_execute, mte): + # For channel 1 we simulate unconfigured IP + # and for any other we return a correct IP address + def side_effect(*args, **kwargs): + if args[0].startswith("ipmitool lan print 1"): + return '', 'Invalid channel 1\n' + elif args[0].startswith("ipmitool lan print 2"): + return '0.0.0.0\n00:00:00:00:23:42', '' + elif args[0].startswith("ipmitool lan print 3"): + return 'meow', '' + elif args[0].startswith("ipmitool lan print 4"): + return '192.1.2.3\n01:02:03:04:05:06', '' + else: + # this should never happen because the previous one was good + raise AssertionError + mocked_execute.side_effect = side_effect + self.assertEqual('01:02:03:04:05:06', self.hardware.get_bmc_mac()) + + @mock.patch.object(il_utils, 'try_execute', autospec=True) + @mock.patch.object(utils, 'execute', autospec=True) def test_get_bmc_v6address_not_enabled(self, mocked_execute, mte): mocked_execute.side_effect = [('ipv4\n', '')] * 11 self.assertEqual('::/0', self.hardware.get_bmc_v6address()) diff --git a/ironic_python_agent/utils.py b/ironic_python_agent/utils.py index 2fc60ff1..74ac328b 100644 --- a/ironic_python_agent/utils.py +++ b/ironic_python_agent/utils.py @@ -865,8 +865,7 @@ def create_partition_table(dev_name, partition_table_type): :raises: CommandExecutionError if an error is encountered while attempting to create the partition table. """ - LOG.info("Creating partition table on {}".format( - dev_name)) + LOG.info("Creating partition table on %s", dev_name) try: execute('parted', dev_name, '-s', '--', 'mklabel', partition_table_type) diff --git a/lower-constraints.txt b/lower-constraints.txt new file mode 100644 index 00000000..6e5fc9bc --- /dev/null +++ b/lower-constraints.txt @@ -0,0 +1,26 @@ +Pint==0.5 +Werkzeug==1.0.1 +bandit==1.1.0 +coverage==4.0 +cryptography==2.3 +dogpile.cache==0.9.2 +eventlet==0.18.2 +importlib_metadata==1.7.0;python_version<'3.8' +ironic-lib==4.7.1 +netifaces==0.10.4 +openstacksdk==0.49.0 +oslo.concurrency==3.26.0 +oslo.config==5.2.0 +oslo.log==3.36.0 +oslo.serialization==2.18.0 +oslo.service==1.24.0 +oslo.utils==3.33.0 +oslotest==3.2.0 +pbr==2.0.0 +psutil==3.2.2 +pyudev==0.18 +requests==2.14.2 +stestr==1.0.0 +stevedore==1.20.0 +tenacity==6.2.0 +testtools==2.2.0 diff --git a/releasenotes/notes/bmc-mac-introspection-e4c2e203d8529710.yaml b/releasenotes/notes/bmc-mac-introspection-e4c2e203d8529710.yaml new file mode 100644 index 00000000..296cf15c --- /dev/null +++ b/releasenotes/notes/bmc-mac-introspection-e4c2e203d8529710.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + The introspection now includes the MAC address of the IPMI LAN channel + which has a valid IP address and MAC address assigned in the hardware + inventory data as ``bmc_mac``. @@ -35,10 +35,10 @@ commands = stestr run {posargs} [testenv:pep8] usedevelop = False deps= - hacking>=3.1.0,<4.0.0 # Apache-2.0 + hacking>=4.1.0,<5.0.0 # Apache-2.0 bashate>=0.5.1 # Apache-2.0 flake8-import-order>=0.17.1 # LGPLv3 - pycodestyle>=2.0.0,<2.7.0 # MIT + pycodestyle>=2.0.0,<3.0.0 # MIT doc8>=0.8.1 # Apache-2.0 allowlist_externals = bash commands = @@ -137,3 +137,9 @@ commands = pip install -e {toxinidir}/examples/business-logic pip install -e {toxinidir}/examples/vendor-device python -c 'import example_business_logic; import example_device' + +[testenv:lower-constraints] +deps = + -c{toxinidir}/lower-constraints.txt + -r{toxinidir}/test-requirements.txt + -r{toxinidir}/requirements.txt diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml index ff5161d6..a995a8f3 100644 --- a/zuul.d/project.yaml +++ b/zuul.d/project.yaml @@ -1,9 +1,10 @@ - project: templates: - - openstack-python3-xena-jobs + - check-requirements - openstack-cover-jobs + - openstack-lower-constraints-master-branch-jobs + - openstack-python3-xena-jobs - publish-openstack-docs-pti - - check-requirements - release-notes-jobs-python3 check: jobs: |