From 518c338ca3ce749ccf73ce0a357fc081e1593f7a Mon Sep 17 00:00:00 2001
From: Nikolay Fedotov <nfedotov@cisco.com>
Date: Mon, 4 Mar 2019 15:31:14 +0300
Subject: [PATCH] Discover IPv6 BMC address

Change-Id: Ia7f24c1df782de4809a5195876bbf0309b481fd7
Story: #2005133
Task: #29817
---
 ironic_python_agent/hardware.py               |  84 +++++++++++-
 .../tests/unit/test_hardware.py               | 127 ++++++++++++++++++
 ...ver-ipv6-bmc-address-b3b357ff6c5d822c.yaml |   5 +
 3 files changed, 212 insertions(+), 4 deletions(-)
 create mode 100644 releasenotes/notes/discover-ipv6-bmc-address-b3b357ff6c5d822c.yaml

diff --git a/ironic_python_agent/hardware.py b/ironic_python_agent/hardware.py
index 92a9db543..646aced70 100644
--- a/ironic_python_agent/hardware.py
+++ b/ironic_python_agent/hardware.py
@@ -18,6 +18,7 @@ import functools
 import json
 from multiprocessing.pool import ThreadPool
 import os
+import re
 import shlex
 import time
 
@@ -32,6 +33,7 @@ import psutil
 import pyudev
 import six
 import stevedore
+import yaml
 
 from ironic_python_agent import encoding
 from ironic_python_agent import errors
@@ -380,6 +382,9 @@ class HardwareManager(object):
     def get_bmc_address(self):
         raise errors.IncompatibleHardwareMethodError()
 
+    def get_bmc_v6address(self):
+        raise errors.IncompatibleHardwareMethodError()
+
     def get_boot_info(self):
         raise errors.IncompatibleHardwareMethodError()
 
@@ -493,6 +498,7 @@ class HardwareManager(object):
         hardware_info['disks'] = self.list_block_devices()
         hardware_info['memory'] = self.get_memory()
         hardware_info['bmc_address'] = self.get_bmc_address()
+        hardware_info['bmc_v6address'] = self.get_bmc_v6address()
         hardware_info['system_vendor'] = self.get_system_vendor_info()
         hardware_info['boot'] = self.get_boot_info()
         return hardware_info
@@ -1136,6 +1142,76 @@ class GenericHardwareManager(HardwareManager):
 
         return '0.0.0.0'
 
+    def get_bmc_v6address(self):
+        """Attempt to detect BMC v6 address
+
+        :return: IPv6 address of lan channel or ::/0 in case none of them is
+                 configured properly. May return None value if it cannot
+                 interract with system tools or critical error occurs.
+        """
+        # These modules are rarely loaded automatically
+        utils.try_execute('modprobe', 'ipmi_msghandler')
+        utils.try_execute('modprobe', 'ipmi_devintf')
+        utils.try_execute('modprobe', 'ipmi_si')
+
+        null_address_re = re.compile(r'^::(/\d{1,3})*$')
+
+        def get_addr(channel, dynamic=False):
+            cmd = "ipmitool lan6 print {} {}_addr".format(
+                channel, 'dynamic' if dynamic else 'static')
+            try:
+                out, e = utils.execute(cmd, shell=True)
+            except processutils.ProcessExecutionError:
+                return
+
+            # NOTE: More likely ipmitool was not intended to return
+            #       stdout in yaml format. Fortunately, output of
+            #       dynamic_addr and static_addr commands is a valid yaml.
+            try:
+                out = yaml.safe_load(out.strip())
+            except yaml.YAMLError as e:
+                LOG.warning('Cannot process output of "%(cmd)s" '
+                            'command: %(e)s', {'cmd': cmd, 'e': e})
+                return
+
+            for addr_dict in out.values():
+                address = addr_dict['Address']
+                if dynamic:
+                    enabled = addr_dict['Source/Type'] in ['DHCPv6', 'SLAAC']
+                else:
+                    enabled = addr_dict['Enabled']
+
+                if addr_dict['Status'] == 'active' and enabled \
+                        and not null_address_re.match(address):
+                    return address
+
+        try:
+            # From all the channels 0-15, only 1-7 can be assigned to different
+            # types of communication media and protocols and effectively used
+            for channel in range(1, 8):
+                addr_mode, e = utils.execute(
+                    r"ipmitool lan6 print {} enables | "
+                    r"awk '/IPv6\/IPv4 Addressing Enables[ \t]*:/"
+                    r"{{print $NF}}'".format(channel), shell=True)
+                if addr_mode.strip() not in ['ipv6', 'both']:
+                    continue
+
+                address = get_addr(channel, dynamic=True) or get_addr(channel)
+                if not address:
+                    continue
+
+                try:
+                    return str(netaddr.IPNetwork(address).ip)
+                except netaddr.AddrFormatError:
+                    LOG.warning('Invalid IP address: %s', address)
+                    continue
+        except (processutils.ProcessExecutionError, OSError) as e:
+            # Not error, because it's normal in virtual environment
+            LOG.warning("Cannot get BMC v6 address: %s", e)
+            return
+
+        return '::/0'
+
     def get_clean_steps(self, node, ports):
         return [
             {
@@ -1213,8 +1289,8 @@ def dispatch_to_all_managers(method, *args, **kwargs):
     {HardwareManagerClassName: response}.
 
     :param method: hardware manager method to dispatch
-    :param *args: arguments to dispatched method
-    :param **kwargs: keyword arguments to dispatched method
+    :param args: arguments to dispatched method
+    :param kwargs: keyword arguments to dispatched method
     :raises errors.HardwareManagerMethodNotFound: if all managers raise
         IncompatibleHardwareMethodError.
     :returns: a dictionary with keys for each hardware manager that returns
@@ -1257,8 +1333,8 @@ def dispatch_to_managers(method, *args, **kwargs):
     any result without raising an IncompatibleHardwareMethodError.
 
     :param method: hardware manager method to dispatch
-    :param *args: arguments to dispatched method
-    :param **kwargs: keyword arguments to dispatched method
+    :param args: arguments to dispatched method
+    :param kwargs: keyword arguments to dispatched method
 
     :returns: result of successful dispatch of method
     :raises HardwareManagerMethodNotFound: if all managers failed the method
diff --git a/ironic_python_agent/tests/unit/test_hardware.py b/ironic_python_agent/tests/unit/test_hardware.py
index 6b7873a34..3ea5d3137 100644
--- a/ironic_python_agent/tests/unit/test_hardware.py
+++ b/ironic_python_agent/tests/unit/test_hardware.py
@@ -392,6 +392,37 @@ ATA Security is:  Unavailable
 """)  # noqa
 
 
+IPMITOOL_LAN6_PRINT_DYNAMIC_ADDR = """
+IPv6 Dynamic Address 0:
+    Source/Type:    DHCPv6
+    Address:        2001:1234:1234:1234:1234:1234:1234:1234/64
+    Status:         active
+IPv6 Dynamic Address 1:
+    Source/Type:    DHCPv6
+    Address:        ::/0
+    Status:         active
+IPv6 Dynamic Address 2:
+    Source/Type:    DHCPv6
+    Address:        ::/0
+    Status:         active
+"""
+
+IPMITOOL_LAN6_PRINT_STATIC_ADDR = """
+IPv6 Static Address 0:
+    Enabled:        yes
+    Address:        2001:5678:5678:5678:5678:5678:5678:5678/64
+    Status:         active
+IPv6 Static Address 1:
+    Enabled:        no
+    Address:        ::/0
+    Status:         disabled
+IPv6 Static Address 2:
+    Enabled:        no
+    Address:        ::/0
+    Status:         disabled
+"""
+
+
 class FakeHardwareManager(hardware.GenericHardwareManager):
     def __init__(self, hardware_support):
         self._hardware_support = hardware_support
@@ -1126,6 +1157,7 @@ class TestGenericHardwareManager(base.IronicAgentTest):
             current_boot_mode='bios', pxe_interface='boot:if')
 
         self.hardware.get_bmc_address = mock.Mock()
+        self.hardware.get_bmc_v6address = mock.Mock()
         self.hardware.get_system_vendor_info = mock.Mock()
 
         hardware_info = self.hardware.list_hardware_info()
@@ -2085,6 +2117,101 @@ class TestGenericHardwareManager(base.IronicAgentTest):
         mocked_execute.return_value = '', ''
         self.assertEqual('0.0.0.0', self.hardware.get_bmc_address())
 
+    @mock.patch.object(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', '')] * 7
+        self.assertEqual('::/0', self.hardware.get_bmc_v6address())
+
+    @mock.patch.object(utils, 'try_execute', autospec=True)
+    @mock.patch.object(utils, 'execute', autospec=True)
+    def test_get_bmc_v6address_dynamic_address(self, mocked_execute, mte):
+        mocked_execute.side_effect = [
+            ('ipv6\n', ''),
+            (IPMITOOL_LAN6_PRINT_DYNAMIC_ADDR, '')
+        ]
+        self.assertEqual('2001:1234:1234:1234:1234:1234:1234:1234',
+                         self.hardware.get_bmc_v6address())
+
+    @mock.patch.object(utils, 'try_execute', autospec=True)
+    @mock.patch.object(utils, 'execute', autospec=True)
+    def test_get_bmc_v6address_static_address_both(self, mocked_execute, mte):
+        dynamic_disabled = \
+            IPMITOOL_LAN6_PRINT_DYNAMIC_ADDR.replace('active', 'disabled')
+        mocked_execute.side_effect = [
+            ('both\n', ''),
+            (dynamic_disabled, ''),
+            (IPMITOOL_LAN6_PRINT_STATIC_ADDR, '')
+        ]
+        self.assertEqual('2001:5678:5678:5678:5678:5678:5678:5678',
+                         self.hardware.get_bmc_v6address())
+
+    @mock.patch.object(utils, 'execute', autospec=True)
+    def test_get_bmc_v6address_virt(self, mocked_execute):
+        mocked_execute.side_effect = processutils.ProcessExecutionError()
+        self.assertIsNone(self.hardware.get_bmc_v6address())
+
+    @mock.patch.object(utils, 'try_execute', autospec=True)
+    @mock.patch.object(utils, 'execute', autospec=True)
+    def test_get_bmc_v6address_invalid_enables(self, mocked_execute, mte):
+        def side_effect(*args, **kwargs):
+            if args[0].startswith('ipmitool lan6 print'):
+                return '', 'Failed to get IPv6/IPv4 Addressing Enables'
+
+        mocked_execute.side_effect = side_effect
+        self.assertEqual('::/0', self.hardware.get_bmc_v6address())
+
+    @mock.patch.object(utils, 'try_execute', autospec=True)
+    @mock.patch.object(utils, 'execute', autospec=True)
+    def test_get_bmc_v6address_invalid_get_address(self, mocked_execute, mte):
+        def side_effect(*args, **kwargs):
+            if args[0].startswith('ipmitool lan6 print'):
+                if args[0].endswith('dynamic_addr') \
+                        or args[0].endswith('static_addr'):
+                    raise processutils.ProcessExecutionError()
+                return 'ipv6', ''
+
+        mocked_execute.side_effect = side_effect
+        self.assertEqual('::/0', self.hardware.get_bmc_v6address())
+
+    @mock.patch.object(hardware, 'LOG', autospec=True)
+    @mock.patch.object(utils, 'try_execute', autospec=True)
+    @mock.patch.object(utils, 'execute', autospec=True)
+    def test_get_bmc_v6address_impitool_invalid_stdout_format(
+            self, mocked_execute, mte, mocked_log):
+        def side_effect(*args, **kwargs):
+            if args[0].startswith('ipmitool lan6 print'):
+                if args[0].endswith('dynamic_addr') \
+                        or args[0].endswith('static_addr'):
+                    return 'Invalid\n\tyaml', ''
+                return 'ipv6', ''
+
+        mocked_execute.side_effect = side_effect
+        self.assertEqual('::/0', self.hardware.get_bmc_v6address())
+        one_call = mock.call('Cannot process output of "%(cmd)s" '
+                             'command: %(e)s', mock.ANY)
+        mocked_log.warning.assert_has_calls([one_call] * 14)
+
+    @mock.patch.object(utils, 'try_execute', autospec=True)
+    @mock.patch.object(utils, 'execute', autospec=True)
+    def test_get_bmc_v6address_channel_7(self, mocked_execute, mte):
+        def side_effect(*args, **kwargs):
+            if not args[0].startswith('ipmitool lan6 print 7'):
+                # ipv6 is not enabled for channels 1-6
+                if 'enables |' in args[0]:
+                    return '', ''
+            else:
+                if 'enables |' in args[0]:
+                    return 'ipv6', ''
+                if args[0].endswith('dynamic_addr'):
+                    raise processutils.ProcessExecutionError()
+                elif args[0].endswith('static_addr'):
+                    return IPMITOOL_LAN6_PRINT_STATIC_ADDR, ''
+
+        mocked_execute.side_effect = side_effect
+        self.assertEqual('2001:5678:5678:5678:5678:5678:5678:5678',
+                         self.hardware.get_bmc_v6address())
+
     @mock.patch.object(utils, 'execute', autospec=True)
     def test_get_system_vendor_info(self, mocked_execute):
         mocked_execute.return_value = LSHW_JSON_OUTPUT
diff --git a/releasenotes/notes/discover-ipv6-bmc-address-b3b357ff6c5d822c.yaml b/releasenotes/notes/discover-ipv6-bmc-address-b3b357ff6c5d822c.yaml
new file mode 100644
index 000000000..73ebfabe3
--- /dev/null
+++ b/releasenotes/notes/discover-ipv6-bmc-address-b3b357ff6c5d822c.yaml
@@ -0,0 +1,5 @@
+---
+features:
+  - |
+    Discover IPv6 BMC address and store it in "bmc_v6address"
+    field of hardware inventory sent back to inspector.