bb836b
From b48dda73da94782d7ab0c455fa382d3a5ef3c419 Mon Sep 17 00:00:00 2001
8855a0
From: Daniel Watkins <oddbloke@ubuntu.com>
8855a0
Date: Mon, 8 Mar 2021 12:50:57 -0500
bb836b
Subject: net: exclude OVS internal interfaces in get_interfaces (#829)
8855a0
bb836b
`get_interfaces` is used to in two ways, broadly: firstly, to determine
bb836b
the available interfaces when converting cloud network configuration
bb836b
formats to cloud-init's network configuration formats; and, secondly, to
bb836b
ensure that any interfaces which are specified in network configuration
bb836b
are (a) available, and (b) named correctly.  The first of these is
bb836b
unaffected by this commit, as no clouds support Open vSwitch
bb836b
configuration in their network configuration formats.
8855a0
bb836b
For the second, we check that MAC addresses of physical devices are
bb836b
unique.  In some OVS configurations, there are OVS-created devices which
bb836b
have duplicate MAC addresses, either with each other or with physical
bb836b
devices.  As these interfaces are created by OVS, we can be confident
bb836b
that (a) they will be available when appropriate, and (b) that OVS will
bb836b
name them correctly.  As such, this commit excludes any OVS-internal
bb836b
interfaces from the set of interfaces returned by `get_interfaces`.
8855a0
bb836b
LP: #1912844
8855a0
---
8855a0
 cloudinit/net/__init__.py                     |  62 +++++++++
8855a0
 cloudinit/net/tests/test_init.py              | 119 ++++++++++++++++++
8855a0
 .../sources/helpers/tests/test_openstack.py   |   5 +
8855a0
 cloudinit/sources/tests/test_oracle.py        |   4 +
8855a0
 .../integration_tests/bugs/test_lp1912844.py  | 103 +++++++++++++++
8855a0
 .../test_datasource/test_configdrive.py       |   8 ++
8855a0
 tests/unittests/test_net.py                   |  20 +++
8855a0
 7 files changed, 321 insertions(+)
8855a0
 create mode 100644 tests/integration_tests/bugs/test_lp1912844.py
8855a0
8855a0
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
bb836b
index de65e7af..385b7bcc 100644
8855a0
--- a/cloudinit/net/__init__.py
8855a0
+++ b/cloudinit/net/__init__.py
8855a0
@@ -6,6 +6,7 @@
8855a0
 # This file is part of cloud-init. See LICENSE file for license information.
8855a0
 
8855a0
 import errno
8855a0
+import functools
8855a0
 import ipaddress
8855a0
 import logging
8855a0
 import os
8855a0
@@ -19,6 +20,19 @@ from cloudinit.url_helper import UrlError, readurl
8855a0
 LOG = logging.getLogger(__name__)
8855a0
 SYS_CLASS_NET = "/sys/class/net/"
8855a0
 DEFAULT_PRIMARY_INTERFACE = 'eth0'
8855a0
+OVS_INTERNAL_INTERFACE_LOOKUP_CMD = [
8855a0
+    "ovs-vsctl",
8855a0
+    "--format",
8855a0
+    "csv",
8855a0
+    "--no-headings",
8855a0
+    "--timeout",
8855a0
+    "10",
8855a0
+    "--columns",
8855a0
+    "name",
8855a0
+    "find",
8855a0
+    "interface",
8855a0
+    "type=internal",
8855a0
+]
8855a0
 
8855a0
 
8855a0
 def natural_sort_key(s, _nsre=re.compile('([0-9]+)')):
8855a0
@@ -133,6 +147,52 @@ def master_is_openvswitch(devname):
8855a0
     return os.path.exists(ovs_path)
8855a0
 
8855a0
 
8855a0
+@functools.lru_cache(maxsize=None)
8855a0
+def openvswitch_is_installed() -> bool:
8855a0
+    """Return a bool indicating if Open vSwitch is installed in the system."""
8855a0
+    ret = bool(subp.which("ovs-vsctl"))
8855a0
+    if not ret:
8855a0
+        LOG.debug(
8855a0
+            "ovs-vsctl not in PATH; not detecting Open vSwitch interfaces"
8855a0
+        )
8855a0
+    return ret
8855a0
+
8855a0
+
8855a0
+@functools.lru_cache(maxsize=None)
8855a0
+def get_ovs_internal_interfaces() -> list:
8855a0
+    """Return a list of the names of OVS internal interfaces on the system.
8855a0
+
8855a0
+    These will all be strings, and are used to exclude OVS-specific interface
8855a0
+    from cloud-init's network configuration handling.
8855a0
+    """
8855a0
+    try:
8855a0
+        out, _err = subp.subp(OVS_INTERNAL_INTERFACE_LOOKUP_CMD)
8855a0
+    except subp.ProcessExecutionError as exc:
8855a0
+        if "database connection failed" in exc.stderr:
8855a0
+            LOG.info(
8855a0
+                "Open vSwitch is not yet up; no interfaces will be detected as"
8855a0
+                " OVS-internal"
8855a0
+            )
8855a0
+            return []
8855a0
+        raise
8855a0
+    else:
8855a0
+        return out.splitlines()
8855a0
+
8855a0
+
8855a0
+def is_openvswitch_internal_interface(devname: str) -> bool:
8855a0
+    """Returns True if this is an OVS internal interface.
8855a0
+
8855a0
+    If OVS is not installed or not yet running, this will return False.
8855a0
+    """
8855a0
+    if not openvswitch_is_installed():
8855a0
+        return False
8855a0
+    ovs_bridges = get_ovs_internal_interfaces()
8855a0
+    if devname in ovs_bridges:
8855a0
+        LOG.debug("Detected %s as an OVS interface", devname)
8855a0
+        return True
8855a0
+    return False
8855a0
+
8855a0
+
8855a0
 def is_netfailover(devname, driver=None):
8855a0
     """ netfailover driver uses 3 nics, master, primary and standby.
8855a0
         this returns True if the device is either the primary or standby
bb836b
@@ -884,6 +944,8 @@ def get_interfaces(blacklist_drivers=None) -> list:
8855a0
         # skip nics that have no mac (00:00....)
8855a0
         if name != 'lo' and mac == zero_mac[:len(mac)]:
8855a0
             continue
8855a0
+        if is_openvswitch_internal_interface(name):
8855a0
+            continue
bb836b
         # skip nics that have drivers blacklisted
bb836b
         driver = device_driver(name)
bb836b
         if driver in blacklist_drivers:
8855a0
diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
8855a0
index 0535387a..946f8ee2 100644
8855a0
--- a/cloudinit/net/tests/test_init.py
8855a0
+++ b/cloudinit/net/tests/test_init.py
8855a0
@@ -391,6 +391,10 @@ class TestGetDeviceList(CiTestCase):
8855a0
         self.assertCountEqual(['eth0', 'eth1'], net.get_devicelist())
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False),
8855a0
+)
8855a0
 class TestGetInterfaceMAC(CiTestCase):
8855a0
 
8855a0
     def setUp(self):
8855a0
@@ -1224,6 +1228,121 @@ class TestNetFailOver(CiTestCase):
8855a0
         self.assertFalse(net.is_netfailover(devname, driver))
8855a0
 
8855a0
 
8855a0
+class TestOpenvswitchIsInstalled:
8855a0
+    """Test cloudinit.net.openvswitch_is_installed.
8855a0
+
8855a0
+    Uses the ``clear_lru_cache`` local autouse fixture to allow us to test
8855a0
+    despite the ``lru_cache`` decorator on the unit under test.
8855a0
+    """
8855a0
+
8855a0
+    @pytest.fixture(autouse=True)
8855a0
+    def clear_lru_cache(self):
8855a0
+        net.openvswitch_is_installed.cache_clear()
8855a0
+
8855a0
+    @pytest.mark.parametrize(
8855a0
+        "expected,which_return", [(True, "/some/path"), (False, None)]
8855a0
+    )
8855a0
+    @mock.patch("cloudinit.net.subp.which")
8855a0
+    def test_mirrors_which_result(self, m_which, expected, which_return):
8855a0
+        m_which.return_value = which_return
8855a0
+        assert expected == net.openvswitch_is_installed()
8855a0
+
8855a0
+    @mock.patch("cloudinit.net.subp.which")
8855a0
+    def test_only_calls_which_once(self, m_which):
8855a0
+        net.openvswitch_is_installed()
8855a0
+        net.openvswitch_is_installed()
8855a0
+        assert 1 == m_which.call_count
8855a0
+
8855a0
+
8855a0
+@mock.patch("cloudinit.net.subp.subp", return_value=("", ""))
8855a0
+class TestGetOVSInternalInterfaces:
8855a0
+    """Test cloudinit.net.get_ovs_internal_interfaces.
8855a0
+
8855a0
+    Uses the ``clear_lru_cache`` local autouse fixture to allow us to test
8855a0
+    despite the ``lru_cache`` decorator on the unit under test.
8855a0
+    """
8855a0
+    @pytest.fixture(autouse=True)
8855a0
+    def clear_lru_cache(self):
8855a0
+        net.get_ovs_internal_interfaces.cache_clear()
8855a0
+
8855a0
+    def test_command_used(self, m_subp):
8855a0
+        """Test we use the correct command when we call subp"""
8855a0
+        net.get_ovs_internal_interfaces()
8855a0
+
8855a0
+        assert [
8855a0
+            mock.call(net.OVS_INTERNAL_INTERFACE_LOOKUP_CMD)
8855a0
+        ] == m_subp.call_args_list
8855a0
+
8855a0
+    def test_subp_contents_split_and_returned(self, m_subp):
8855a0
+        """Test that the command output is appropriately mangled."""
8855a0
+        stdout = "iface1\niface2\niface3\n"
8855a0
+        m_subp.return_value = (stdout, "")
8855a0
+
8855a0
+        assert [
8855a0
+            "iface1",
8855a0
+            "iface2",
8855a0
+            "iface3",
8855a0
+        ] == net.get_ovs_internal_interfaces()
8855a0
+
8855a0
+    def test_database_connection_error_handled_gracefully(self, m_subp):
8855a0
+        """Test that the error indicating OVS is down is handled gracefully."""
8855a0
+        m_subp.side_effect = ProcessExecutionError(
8855a0
+            stderr="database connection failed"
8855a0
+        )
8855a0
+
8855a0
+        assert [] == net.get_ovs_internal_interfaces()
8855a0
+
8855a0
+    def test_other_errors_raised(self, m_subp):
8855a0
+        """Test that only database connection errors are handled."""
8855a0
+        m_subp.side_effect = ProcessExecutionError()
8855a0
+
8855a0
+        with pytest.raises(ProcessExecutionError):
8855a0
+            net.get_ovs_internal_interfaces()
8855a0
+
8855a0
+    def test_only_runs_once(self, m_subp):
8855a0
+        """Test that we cache the value."""
8855a0
+        net.get_ovs_internal_interfaces()
8855a0
+        net.get_ovs_internal_interfaces()
8855a0
+
8855a0
+        assert 1 == m_subp.call_count
8855a0
+
8855a0
+
8855a0
+@mock.patch("cloudinit.net.get_ovs_internal_interfaces")
8855a0
+@mock.patch("cloudinit.net.openvswitch_is_installed")
8855a0
+class TestIsOpenVSwitchInternalInterface:
8855a0
+    def test_false_if_ovs_not_installed(
8855a0
+        self, m_openvswitch_is_installed, _m_get_ovs_internal_interfaces
8855a0
+    ):
8855a0
+        """Test that OVS' absence returns False."""
8855a0
+        m_openvswitch_is_installed.return_value = False
8855a0
+
8855a0
+        assert not net.is_openvswitch_internal_interface("devname")
8855a0
+
8855a0
+    @pytest.mark.parametrize(
8855a0
+        "detected_interfaces,devname,expected_return",
8855a0
+        [
8855a0
+            ([], "devname", False),
8855a0
+            (["notdevname"], "devname", False),
8855a0
+            (["devname"], "devname", True),
8855a0
+            (["some", "other", "devices", "and", "ours"], "ours", True),
8855a0
+        ],
8855a0
+    )
8855a0
+    def test_return_value_based_on_detected_interfaces(
8855a0
+        self,
8855a0
+        m_openvswitch_is_installed,
8855a0
+        m_get_ovs_internal_interfaces,
8855a0
+        detected_interfaces,
8855a0
+        devname,
8855a0
+        expected_return,
8855a0
+    ):
8855a0
+        """Test that the detected interfaces are used correctly."""
8855a0
+        m_openvswitch_is_installed.return_value = True
8855a0
+        m_get_ovs_internal_interfaces.return_value = detected_interfaces
8855a0
+        assert expected_return == net.is_openvswitch_internal_interface(
8855a0
+            devname
8855a0
+        )
8855a0
+
8855a0
+
8855a0
 class TestIsIpAddress:
8855a0
     """Tests for net.is_ip_address.
8855a0
 
8855a0
diff --git a/cloudinit/sources/helpers/tests/test_openstack.py b/cloudinit/sources/helpers/tests/test_openstack.py
8855a0
index 2bde1e3f..95fb9743 100644
8855a0
--- a/cloudinit/sources/helpers/tests/test_openstack.py
8855a0
+++ b/cloudinit/sources/helpers/tests/test_openstack.py
8855a0
@@ -1,10 +1,15 @@
8855a0
 # This file is part of cloud-init. See LICENSE file for license information.
8855a0
 # ./cloudinit/sources/helpers/tests/test_openstack.py
8855a0
+from unittest import mock
8855a0
 
8855a0
 from cloudinit.sources.helpers import openstack
8855a0
 from cloudinit.tests import helpers as test_helpers
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestConvertNetJson(test_helpers.CiTestCase):
8855a0
 
8855a0
     def test_phy_types(self):
8855a0
diff --git a/cloudinit/sources/tests/test_oracle.py b/cloudinit/sources/tests/test_oracle.py
bb836b
index a7bbdfd9..dcf33b9b 100644
8855a0
--- a/cloudinit/sources/tests/test_oracle.py
8855a0
+++ b/cloudinit/sources/tests/test_oracle.py
8855a0
@@ -173,6 +173,10 @@ class TestIsPlatformViable(test_helpers.CiTestCase):
8855a0
         m_read_dmi_data.assert_has_calls([mock.call('chassis-asset-tag')])
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestNetworkConfigFromOpcImds:
8855a0
     def test_no_secondary_nics_does_not_mutate_input(self, oracle_ds):
8855a0
         oracle_ds._vnics_data = [{}]
8855a0
diff --git a/tests/integration_tests/bugs/test_lp1912844.py b/tests/integration_tests/bugs/test_lp1912844.py
8855a0
new file mode 100644
8855a0
index 00000000..efafae50
8855a0
--- /dev/null
8855a0
+++ b/tests/integration_tests/bugs/test_lp1912844.py
8855a0
@@ -0,0 +1,103 @@
8855a0
+"""Integration test for LP: #1912844
8855a0
+
8855a0
+cloud-init should ignore OVS-internal interfaces when performing its own
8855a0
+interface determination: these interfaces are handled fully by OVS, so
8855a0
+cloud-init should never need to touch them.
8855a0
+
8855a0
+This test is a semi-synthetic reproducer for the bug.  It uses a similar
8855a0
+network configuration, tweaked slightly to DHCP in a way that will succeed even
8855a0
+on "failed" boots.  The exact bug doesn't reproduce with the NoCloud
8855a0
+datasource, because it runs at init-local time (whereas the MAAS datasource,
8855a0
+from the report, runs only at init (network) time): this means that the
8855a0
+networking code runs before OVS creates its interfaces (which happens after
8855a0
+init-local but, of course, before networking is up), and so doesn't generate
8855a0
+the traceback that they cause.  We work around this by calling
8855a0
+``get_interfaces_by_mac` directly in the test code.
8855a0
+"""
8855a0
+import pytest
8855a0
+
8855a0
+from tests.integration_tests import random_mac_address
8855a0
+
8855a0
+MAC_ADDRESS = random_mac_address()
8855a0
+
8855a0
+NETWORK_CONFIG = """\
8855a0
+bonds:
8855a0
+    bond0:
8855a0
+        interfaces:
8855a0
+            - enp5s0
8855a0
+        macaddress: {0}
8855a0
+        mtu: 1500
8855a0
+bridges:
8855a0
+        ovs-br:
8855a0
+            interfaces:
8855a0
+            - bond0
8855a0
+            macaddress: {0}
8855a0
+            mtu: 1500
8855a0
+            openvswitch: {{}}
8855a0
+            dhcp4: true
8855a0
+ethernets:
8855a0
+    enp5s0:
8855a0
+      mtu: 1500
8855a0
+      set-name: enp5s0
8855a0
+      match:
8855a0
+          macaddress: {0}
8855a0
+version: 2
8855a0
+vlans:
8855a0
+  ovs-br.100:
8855a0
+    id: 100
8855a0
+    link: ovs-br
8855a0
+    mtu: 1500
8855a0
+  ovs-br.200:
8855a0
+    id: 200
8855a0
+    link: ovs-br
8855a0
+    mtu: 1500
8855a0
+""".format(MAC_ADDRESS)
8855a0
+
8855a0
+
8855a0
+SETUP_USER_DATA = """\
8855a0
+#cloud-config
8855a0
+packages:
8855a0
+- openvswitch-switch
8855a0
+"""
8855a0
+
8855a0
+
8855a0
+@pytest.fixture
8855a0
+def ovs_enabled_session_cloud(session_cloud):
8855a0
+    """A session_cloud wrapper, to use an OVS-enabled image for tests.
8855a0
+
8855a0
+    This implementation is complicated by wanting to use ``session_cloud``s
8855a0
+    snapshot cleanup/retention logic, to avoid having to reimplement that here.
8855a0
+    """
8855a0
+    old_snapshot_id = session_cloud.snapshot_id
8855a0
+    with session_cloud.launch(
8855a0
+        user_data=SETUP_USER_DATA,
8855a0
+    ) as instance:
8855a0
+        instance.instance.clean()
8855a0
+        session_cloud.snapshot_id = instance.snapshot()
8855a0
+
8855a0
+    yield session_cloud
8855a0
+
8855a0
+    try:
8855a0
+        session_cloud.delete_snapshot()
8855a0
+    finally:
8855a0
+        session_cloud.snapshot_id = old_snapshot_id
8855a0
+
8855a0
+
8855a0
+@pytest.mark.lxd_vm
8855a0
+def test_get_interfaces_by_mac_doesnt_traceback(ovs_enabled_session_cloud):
8855a0
+    """Launch our OVS-enabled image and confirm the bug doesn't reproduce."""
8855a0
+    launch_kwargs = {
8855a0
+        "config_dict": {
8855a0
+            "user.network-config": NETWORK_CONFIG,
8855a0
+            "volatile.eth0.hwaddr": MAC_ADDRESS,
8855a0
+        },
8855a0
+    }
8855a0
+    with ovs_enabled_session_cloud.launch(
8855a0
+        launch_kwargs=launch_kwargs,
8855a0
+    ) as client:
8855a0
+        result = client.execute(
8855a0
+            "python3 -c"
8855a0
+            "'from cloudinit.net import get_interfaces_by_mac;"
8855a0
+            "get_interfaces_by_mac()'"
8855a0
+        )
8855a0
+        assert result.ok
8855a0
diff --git a/tests/unittests/test_datasource/test_configdrive.py b/tests/unittests/test_datasource/test_configdrive.py
8855a0
index 6f830cc6..2e2b7847 100644
8855a0
--- a/tests/unittests/test_datasource/test_configdrive.py
8855a0
+++ b/tests/unittests/test_datasource/test_configdrive.py
8855a0
@@ -494,6 +494,10 @@ class TestConfigDriveDataSource(CiTestCase):
8855a0
         self.assertEqual('config-disk (/dev/anything)', cfg_ds.subplatform)
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestNetJson(CiTestCase):
8855a0
     def setUp(self):
8855a0
         super(TestNetJson, self).setUp()
8855a0
@@ -654,6 +658,10 @@ class TestNetJson(CiTestCase):
8855a0
             self.assertEqual(out_data, conv_data)
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestConvertNetworkData(CiTestCase):
8855a0
 
8855a0
     with_logs = True
8855a0
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
bb836b
index c67b5fcc..14d3462f 100644
8855a0
--- a/tests/unittests/test_net.py
8855a0
+++ b/tests/unittests/test_net.py
bb836b
@@ -2908,6 +2908,10 @@ iface eth1 inet dhcp
8855a0
         self.assertEqual(0, mock_settle.call_count)
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestRhelSysConfigRendering(CiTestCase):
8855a0
 
8855a0
     with_logs = True
bb836b
@@ -3592,6 +3596,10 @@ USERCTL=no
8855a0
                 expected, self._render_and_read(network_config=v2data))
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestOpenSuseSysConfigRendering(CiTestCase):
8855a0
 
8855a0
     with_logs = True
bb836b
@@ -5009,6 +5017,10 @@ class TestNetRenderers(CiTestCase):
8855a0
             self.assertTrue(result)
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestGetInterfaces(CiTestCase):
8855a0
     _data = {'bonds': ['bond1'],
8855a0
              'bridges': ['bridge1'],
bb836b
@@ -5158,6 +5170,10 @@ class TestInterfaceHasOwnMac(CiTestCase):
8855a0
         self.assertFalse(interface_has_own_mac("eth0"))
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestGetInterfacesByMac(CiTestCase):
8855a0
     _data = {'bonds': ['bond1'],
8855a0
              'bridges': ['bridge1'],
bb836b
@@ -5314,6 +5330,10 @@ class TestInterfacesSorting(CiTestCase):
8855a0
             ['enp0s3', 'enp0s8', 'enp0s13', 'enp1s2', 'enp2s0', 'enp2s3'])
8855a0
 
8855a0
 
8855a0
+@mock.patch(
8855a0
+    "cloudinit.net.is_openvswitch_internal_interface",
8855a0
+    mock.Mock(return_value=False)
8855a0
+)
8855a0
 class TestGetIBHwaddrsByInterface(CiTestCase):
8855a0
 
8855a0
     _ib_addr = '80:00:00:28:fe:80:00:00:00:00:00:00:00:11:22:03:00:33:44:56'
8855a0
-- 
8855a0
2.27.0
8855a0