1064ba
From 4ada8f2ec17d11835b44ab3d7786e5f3a732df41 Mon Sep 17 00:00:00 2001
1064ba
From: Scott Moser <smoser@brickies.net>
1064ba
Date: Fri, 31 Mar 2017 10:56:04 -0400
1064ba
Subject: [PATCH] Fix bug that resulted in an attempt to rename bonds or vlans.
1064ba
1064ba
When cloud-init ran in the init stage (after networking had come up).
1064ba
A bug could occur where cloud-init would attempt and fail to rename
1064ba
network devices that had "inherited" mac addresses.
1064ba
1064ba
The intent of apply_network_config_names was always to rename only
1064ba
the devices that were "physical" per the network config.  (This would
1064ba
include veth devices in a container).  The bug was in creating
1064ba
the dictionary of interfaces by mac address.  If there were multiple
1064ba
interfaces with the same mac address then renames could fail.
1064ba
This situation was guaranteed to occur with bonds or vlans or other
1064ba
devices that inherit their mac.
1064ba
1064ba
The solution is to change get_interfaces_by_mac to skip interfaces
1064ba
that have an inherited mac.
1064ba
1064ba
Also drop the 'devs' argument to get_interfaces_by_mac.  It was
1064ba
non-obvious what the result should be if a device in the input
1064ba
list was filtered out. ie should the following have an entry for
1064ba
bond0 or not.  get_interfaces_by_mac(devs=['bond0'])
1064ba
1064ba
LP: #1669860
1064ba
(cherry picked from commit bf7723e8092bb1f8a442aa2399dd870e130a27d9)
1064ba
1064ba
Resolves: rhbz#1512247
1064ba
Signed-off-by: Ryan McCabe <rmccabe@redhat.com>
1064ba
(cherry picked from commit f9e8f13f916fe740e46c9a0e9dd2dbb3cdb39975)
1064ba
---
1064ba
 cloudinit/net/__init__.py   | 78 ++++++++++++++++++++++++++++++++++-----------
1064ba
 tests/unittests/test_net.py | 76 +++++++++++++++++++++++++++++++++++++++++++
1064ba
 2 files changed, 135 insertions(+), 19 deletions(-)
1064ba
 mode change 100755 => 100644 cloudinit/net/__init__.py
1064ba
1064ba
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
1064ba
old mode 100755
1064ba
new mode 100644
1064ba
index ea649cc2..ab7e8996
1064ba
--- a/cloudinit/net/__init__.py
1064ba
+++ b/cloudinit/net/__init__.py
1064ba
@@ -82,6 +82,10 @@ def is_wireless(devname):
1064ba
     return os.path.exists(sys_dev_path(devname, "wireless"))
1064ba
 
1064ba
 
1064ba
+def is_bridge(devname):
1064ba
+    return os.path.exists(sys_dev_path(devname, "bridge"))
1064ba
+
1064ba
+
1064ba
 def is_connected(devname):
1064ba
     # is_connected isn't really as simple as that.  2 is
1064ba
     # 'physically connected'. 3 is 'not connected'. but a wlan interface will
1064ba
@@ -132,7 +136,7 @@ def generate_fallback_config():
1064ba
     for interface in potential_interfaces:
1064ba
         if interface.startswith("veth"):
1064ba
             continue
1064ba
-        if os.path.exists(sys_dev_path(interface, "bridge")):
1064ba
+        if is_bridge(interface):
1064ba
             # skip any bridges
1064ba
             continue
1064ba
         carrier = read_sys_net_int(interface, 'carrier')
1064ba
@@ -187,7 +191,11 @@ def apply_network_config_names(netcfg, strict_present=True, strict_busy=True):
1064ba
     """read the network config and rename devices accordingly.
1064ba
     if strict_present is false, then do not raise exception if no devices
1064ba
     match.  if strict_busy is false, then do not raise exception if the
1064ba
-    device cannot be renamed because it is currently configured."""
1064ba
+    device cannot be renamed because it is currently configured.
1064ba
+
1064ba
+    renames are only attempted for interfaces of type 'physical'.  It is
1064ba
+    expected that the network system will create other devices with the
1064ba
+    correct name in place."""
1064ba
     renames = []
1064ba
     for ent in netcfg.get('config', {}):
1064ba
         if ent.get('type') != 'physical':
1064ba
@@ -201,13 +209,35 @@ def apply_network_config_names(netcfg, strict_present=True, strict_busy=True):
1064ba
     return _rename_interfaces(renames)
1064ba
 
1064ba
 
1064ba
+def interface_has_own_mac(ifname, strict=False):
1064ba
+    """return True if the provided interface has its own address.
1064ba
+
1064ba
+    Based on addr_assign_type in /sys.  Return true for any interface
1064ba
+    that does not have a 'stolen' address. Examples of such devices
1064ba
+    are bonds or vlans that inherit their mac from another device.
1064ba
+    Possible values are:
1064ba
+      0: permanent address    2: stolen from another device
1064ba
+      1: randomly generated   3: set using dev_set_mac_address"""
1064ba
+
1064ba
+    assign_type = read_sys_net_int(ifname, "addr_assign_type")
1064ba
+    if strict and assign_type is None:
1064ba
+        raise ValueError("%s had no addr_assign_type.")
1064ba
+    return assign_type in (0, 1, 3)
1064ba
+
1064ba
+
1064ba
 def _get_current_rename_info(check_downable=True):
1064ba
-    """Collect information necessary for rename_interfaces."""
1064ba
-    names = get_devicelist()
1064ba
+    """Collect information necessary for rename_interfaces.
1064ba
+
1064ba
+    returns a dictionary by mac address like:
1064ba
+       {mac:
1064ba
+         {'name': name
1064ba
+          'up': boolean: is_up(name),
1064ba
+          'downable': None or boolean indicating that the
1064ba
+                      device has only automatically assigned ip addrs.}}
1064ba
+    """
1064ba
     bymac = {}
1064ba
-    for n in names:
1064ba
-        bymac[get_interface_mac(n)] = {
1064ba
-            'name': n, 'up': is_up(n), 'downable': None}
1064ba
+    for mac, name in get_interfaces_by_mac().items():
1064ba
+        bymac[mac] = {'name': name, 'up': is_up(name), 'downable': None}
1064ba
 
1064ba
     if check_downable:
1064ba
         nmatch = re.compile(r"[0-9]+:\s+(\w+)[@:]")
1064ba
@@ -346,22 +376,32 @@ def get_interface_mac(ifname):
1064ba
     return read_sys_net_safe(ifname, path)
1064ba
 
1064ba
 
1064ba
-def get_interfaces_by_mac(devs=None):
1064ba
-    """Build a dictionary of tuples {mac: name}"""
1064ba
-    if devs is None:
1064ba
-        try:
1064ba
-            devs = get_devicelist()
1064ba
-        except OSError as e:
1064ba
-            if e.errno == errno.ENOENT:
1064ba
-                devs = []
1064ba
-            else:
1064ba
-                raise
1064ba
+def get_interfaces_by_mac():
1064ba
+    """Build a dictionary of tuples {mac: name}.
1064ba
+
1064ba
+    Bridges and any devices that have a 'stolen' mac are excluded."""
1064ba
+    try:
1064ba
+        devs = get_devicelist()
1064ba
+    except OSError as e:
1064ba
+        if e.errno == errno.ENOENT:
1064ba
+            devs = []
1064ba
+        else:
1064ba
+            raise
1064ba
     ret = {}
1064ba
     for name in devs:
1064ba
+        if not interface_has_own_mac(name):
1064ba
+            continue
1064ba
+        if is_bridge(name):
1064ba
+            continue
1064ba
         mac = get_interface_mac(name)
1064ba
         # some devices may not have a mac (tun0)
1064ba
-        if mac:
1064ba
-            ret[mac] = name
1064ba
+        if not mac:
1064ba
+            continue
1064ba
+        if mac in ret:
1064ba
+            raise RuntimeError(
1064ba
+                "duplicate mac found! both '%s' and '%s' have mac '%s'" %
1064ba
+                (name, ret[mac], mac))
1064ba
+        ret[mac] = name
1064ba
     return ret
1064ba
 
1064ba
 # vi: ts=4 expandtab
1064ba
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
1064ba
index 551370d4..cadaf596 100644
1064ba
--- a/tests/unittests/test_net.py
1064ba
+++ b/tests/unittests/test_net.py
1064ba
@@ -1173,6 +1173,82 @@ class TestEniRoundTrip(TestCase):
1064ba
             expected, [line for line in found if line])
1064ba
 
1064ba
 
1064ba
+class TestGetInterfacesByMac(TestCase):
1064ba
+    _data = {'devices': ['enp0s1', 'enp0s2', 'bond1', 'bridge1',
1064ba
+                         'bridge1-nic', 'tun0'],
1064ba
+             'bonds': ['bond1'],
1064ba
+             'bridges': ['bridge1'],
1064ba
+             'own_macs': ['enp0s1', 'enp0s2', 'bridge1-nic', 'bridge1'],
1064ba
+             'macs': {'enp0s1': 'aa:aa:aa:aa:aa:01',
1064ba
+                      'enp0s2': 'aa:aa:aa:aa:aa:02',
1064ba
+                      'bond1': 'aa:aa:aa:aa:aa:01',
1064ba
+                      'bridge1': 'aa:aa:aa:aa:aa:03',
1064ba
+                      'bridge1-nic': 'aa:aa:aa:aa:aa:03',
1064ba
+                      'tun0': None}}
1064ba
+    data = {}
1064ba
+
1064ba
+    def _se_get_devicelist(self):
1064ba
+        return self.data['devices']
1064ba
+
1064ba
+    def _se_get_interface_mac(self, name):
1064ba
+        return self.data['macs'][name]
1064ba
+
1064ba
+    def _se_is_bridge(self, name):
1064ba
+        return name in self.data['bridges']
1064ba
+
1064ba
+    def _se_interface_has_own_mac(self, name):
1064ba
+        return name in self.data['own_macs']
1064ba
+
1064ba
+    def _mock_setup(self):
1064ba
+        self.data = copy.deepcopy(self._data)
1064ba
+        mocks = ('get_devicelist', 'get_interface_mac', 'is_bridge',
1064ba
+                 'interface_has_own_mac')
1064ba
+        self.mocks = {}
1064ba
+        for n in mocks:
1064ba
+            m = mock.patch('cloudinit.net.' + n,
1064ba
+                           side_effect=getattr(self, '_se_' + n))
1064ba
+            self.addCleanup(m.stop)
1064ba
+            self.mocks[n] = m.start()
1064ba
+
1064ba
+    def test_raise_exception_on_duplicate_macs(self):
1064ba
+        self._mock_setup()
1064ba
+        self.data['macs']['bridge1-nic'] = self.data['macs']['enp0s1']
1064ba
+        self.assertRaises(RuntimeError, net.get_interfaces_by_mac)
1064ba
+
1064ba
+    def test_excludes_any_without_mac_address(self):
1064ba
+        self._mock_setup()
1064ba
+        ret = net.get_interfaces_by_mac()
1064ba
+        self.assertIn('tun0', self._se_get_devicelist())
1064ba
+        self.assertNotIn('tun0', ret.values())
1064ba
+
1064ba
+    def test_excludes_stolen_macs(self):
1064ba
+        self._mock_setup()
1064ba
+        ret = net.get_interfaces_by_mac()
1064ba
+        self.mocks['interface_has_own_mac'].assert_has_calls(
1064ba
+            [mock.call('enp0s1'), mock.call('bond1')], any_order=True)
1064ba
+        self.assertEqual(
1064ba
+            {'aa:aa:aa:aa:aa:01': 'enp0s1', 'aa:aa:aa:aa:aa:02': 'enp0s2',
1064ba
+             'aa:aa:aa:aa:aa:03': 'bridge1-nic'},
1064ba
+            ret)
1064ba
+
1064ba
+    def test_excludes_bridges(self):
1064ba
+        self._mock_setup()
1064ba
+        # add a device 'b1', make all return they have their "own mac",
1064ba
+        # set everything other than 'b1' to be a bridge.
1064ba
+        # then expect b1 is the only thing left.
1064ba
+        self.data['macs']['b1'] = 'aa:aa:aa:aa:aa:b1'
1064ba
+        self.data['devices'].append('b1')
1064ba
+        self.data['bonds'] = []
1064ba
+        self.data['own_macs'] = self.data['devices']
1064ba
+        self.data['bridges'] = [f for f in self.data['devices'] if f != "b1"]
1064ba
+        ret = net.get_interfaces_by_mac()
1064ba
+        self.assertEqual({'aa:aa:aa:aa:aa:b1': 'b1'}, ret)
1064ba
+        self.mocks['is_bridge'].assert_has_calls(
1064ba
+            [mock.call('bridge1'), mock.call('enp0s1'), mock.call('bond1'),
1064ba
+             mock.call('b1')],
1064ba
+            any_order=True)
1064ba
+
1064ba
+
1064ba
 def _gzip_data(data):
1064ba
     with io.BytesIO() as iobuf:
1064ba
         gzfp = gzip.GzipFile(mode="wb", fileobj=iobuf)
1064ba
-- 
1064ba
2.14.3
1064ba