4eb3b8
From ca5b83cee7b45bf56eec258db739cb5fe51b3231 Mon Sep 17 00:00:00 2001
4eb3b8
From: aswinrajamannar <39812128+aswinrajamannar@users.noreply.github.com>
4eb3b8
Date: Mon, 26 Apr 2021 07:28:39 -0700
4eb3b8
Subject: [PATCH 6/7] Azure: Retry net metadata during nic attach for
4eb3b8
 non-timeout errs (#878)
4eb3b8
4eb3b8
RH-Author: Eduardo Otubo <otubo@redhat.com>
4eb3b8
RH-MergeRequest: 45: Add support for userdata on Azure from IMDS
4eb3b8
RH-Commit: [6/7] 4e6e44f017d5ffcb72ac8959a94f80c71fef9560
4eb3b8
RH-Bugzilla: 2023940
4eb3b8
RH-Acked-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
4eb3b8
RH-Acked-by: Mohamed Gamal Morsy <mmorsy@redhat.com>
4eb3b8
4eb3b8
When network interfaces are hot-attached to the VM, attempting to get
4eb3b8
network metadata might return 410 (or 500, 503 etc) because the info
4eb3b8
is not yet available. In those cases, we retry getting the metadata
4eb3b8
before giving up. The only case where we can move on to wait for more
4eb3b8
nic attach events is if the call times out despite retries, which
4eb3b8
means the interface is not likely a primary interface, and we should
4eb3b8
try for more nic attach events.
4eb3b8
---
4eb3b8
 cloudinit/sources/DataSourceAzure.py          | 65 +++++++++++--
4eb3b8
 tests/unittests/test_datasource/test_azure.py | 95 ++++++++++++++++---
4eb3b8
 2 files changed, 140 insertions(+), 20 deletions(-)
4eb3b8
4eb3b8
diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
4eb3b8
index 6d7954ee..d0be6d84 100755
4eb3b8
--- a/cloudinit/sources/DataSourceAzure.py
4eb3b8
+++ b/cloudinit/sources/DataSourceAzure.py
4eb3b8
@@ -17,6 +17,7 @@ from time import sleep
4eb3b8
 from xml.dom import minidom
4eb3b8
 import xml.etree.ElementTree as ET
4eb3b8
 from enum import Enum
4eb3b8
+import requests
4eb3b8
 
4eb3b8
 from cloudinit import dmi
4eb3b8
 from cloudinit import log as logging
4eb3b8
@@ -665,7 +666,9 @@ class DataSourceAzure(sources.DataSource):
4eb3b8
             self,
4eb3b8
             fallback_nic,
4eb3b8
             retries,
4eb3b8
-            md_type=metadata_type.compute):
4eb3b8
+            md_type=metadata_type.compute,
4eb3b8
+            exc_cb=retry_on_url_exc,
4eb3b8
+            infinite=False):
4eb3b8
         """
4eb3b8
         Wrapper for get_metadata_from_imds so that we can have flexibility
4eb3b8
         in which IMDS api-version we use. If a particular instance of IMDS
4eb3b8
@@ -685,7 +688,8 @@ class DataSourceAzure(sources.DataSource):
4eb3b8
                         fallback_nic=fallback_nic,
4eb3b8
                         retries=0,
4eb3b8
                         md_type=md_type,
4eb3b8
-                        api_version=IMDS_VER_WANT
4eb3b8
+                        api_version=IMDS_VER_WANT,
4eb3b8
+                        exc_cb=exc_cb
4eb3b8
                     )
4eb3b8
                 except UrlError as err:
4eb3b8
                     LOG.info(
4eb3b8
@@ -708,7 +712,9 @@ class DataSourceAzure(sources.DataSource):
4eb3b8
             fallback_nic=fallback_nic,
4eb3b8
             retries=retries,
4eb3b8
             md_type=md_type,
4eb3b8
-            api_version=IMDS_VER_MIN
4eb3b8
+            api_version=IMDS_VER_MIN,
4eb3b8
+            exc_cb=exc_cb,
4eb3b8
+            infinite=infinite
4eb3b8
         )
4eb3b8
 
4eb3b8
     def device_name_to_device(self, name):
4eb3b8
@@ -938,6 +944,9 @@ class DataSourceAzure(sources.DataSource):
4eb3b8
         is_primary = False
4eb3b8
         expected_nic_count = -1
4eb3b8
         imds_md = None
4eb3b8
+        metadata_poll_count = 0
4eb3b8
+        metadata_logging_threshold = 1
4eb3b8
+        metadata_timeout_count = 0
4eb3b8
 
4eb3b8
         # For now, only a VM's primary NIC can contact IMDS and WireServer. If
4eb3b8
         # DHCP fails for a NIC, we have no mechanism to determine if the NIC is
4eb3b8
@@ -962,14 +971,48 @@ class DataSourceAzure(sources.DataSource):
4eb3b8
                                     % (ifname, e), logger_func=LOG.error)
4eb3b8
             raise
4eb3b8
 
4eb3b8
+        # Retry polling network metadata for a limited duration only when the
4eb3b8
+        # calls fail due to timeout. This is because the platform drops packets
4eb3b8
+        # going towards IMDS when it is not a primary nic. If the calls fail
4eb3b8
+        # due to other issues like 410, 503 etc, then it means we are primary
4eb3b8
+        # but IMDS service is unavailable at the moment. Retry indefinitely in
4eb3b8
+        # those cases since we cannot move on without the network metadata.
4eb3b8
+        def network_metadata_exc_cb(msg, exc):
4eb3b8
+            nonlocal metadata_timeout_count, metadata_poll_count
4eb3b8
+            nonlocal metadata_logging_threshold
4eb3b8
+
4eb3b8
+            metadata_poll_count = metadata_poll_count + 1
4eb3b8
+
4eb3b8
+            # Log when needed but back off exponentially to avoid exploding
4eb3b8
+            # the log file.
4eb3b8
+            if metadata_poll_count >= metadata_logging_threshold:
4eb3b8
+                metadata_logging_threshold *= 2
4eb3b8
+                report_diagnostic_event(
4eb3b8
+                    "Ran into exception when attempting to reach %s "
4eb3b8
+                    "after %d polls." % (msg, metadata_poll_count),
4eb3b8
+                    logger_func=LOG.error)
4eb3b8
+
4eb3b8
+                if isinstance(exc, UrlError):
4eb3b8
+                    report_diagnostic_event("poll IMDS with %s failed. "
4eb3b8
+                                            "Exception: %s and code: %s" %
4eb3b8
+                                            (msg, exc.cause, exc.code),
4eb3b8
+                                            logger_func=LOG.error)
4eb3b8
+
4eb3b8
+            if exc.cause and isinstance(exc.cause, requests.Timeout):
4eb3b8
+                metadata_timeout_count = metadata_timeout_count + 1
4eb3b8
+                return (metadata_timeout_count <= 10)
4eb3b8
+            return True
4eb3b8
+
4eb3b8
         # Primary nic detection will be optimized in the future. The fact that
4eb3b8
         # primary nic is being attached first helps here. Otherwise each nic
4eb3b8
         # could add several seconds of delay.
4eb3b8
         try:
4eb3b8
             imds_md = self.get_imds_data_with_api_fallback(
4eb3b8
                 ifname,
4eb3b8
-                5,
4eb3b8
-                metadata_type.network
4eb3b8
+                0,
4eb3b8
+                metadata_type.network,
4eb3b8
+                network_metadata_exc_cb,
4eb3b8
+                True
4eb3b8
             )
4eb3b8
         except Exception as e:
4eb3b8
             LOG.warning(
4eb3b8
@@ -2139,7 +2182,9 @@ def _generate_network_config_from_fallback_config() -> dict:
4eb3b8
 def get_metadata_from_imds(fallback_nic,
4eb3b8
                            retries,
4eb3b8
                            md_type=metadata_type.compute,
4eb3b8
-                           api_version=IMDS_VER_MIN):
4eb3b8
+                           api_version=IMDS_VER_MIN,
4eb3b8
+                           exc_cb=retry_on_url_exc,
4eb3b8
+                           infinite=False):
4eb3b8
     """Query Azure's instance metadata service, returning a dictionary.
4eb3b8
 
4eb3b8
     If network is not up, setup ephemeral dhcp on fallback_nic to talk to the
4eb3b8
@@ -2158,7 +2203,7 @@ def get_metadata_from_imds(fallback_nic,
4eb3b8
     kwargs = {'logfunc': LOG.debug,
4eb3b8
               'msg': 'Crawl of Azure Instance Metadata Service (IMDS)',
4eb3b8
               'func': _get_metadata_from_imds,
4eb3b8
-              'args': (retries, md_type, api_version,)}
4eb3b8
+              'args': (retries, exc_cb, md_type, api_version, infinite)}
4eb3b8
     if net.is_up(fallback_nic):
4eb3b8
         return util.log_time(**kwargs)
4eb3b8
     else:
4eb3b8
@@ -2176,14 +2221,16 @@ def get_metadata_from_imds(fallback_nic,
4eb3b8
 @azure_ds_telemetry_reporter
4eb3b8
 def _get_metadata_from_imds(
4eb3b8
         retries,
4eb3b8
+        exc_cb,
4eb3b8
         md_type=metadata_type.compute,
4eb3b8
-        api_version=IMDS_VER_MIN):
4eb3b8
+        api_version=IMDS_VER_MIN,
4eb3b8
+        infinite=False):
4eb3b8
     url = "{}?api-version={}".format(md_type.value, api_version)
4eb3b8
     headers = {"Metadata": "true"}
4eb3b8
     try:
4eb3b8
         response = readurl(
4eb3b8
             url, timeout=IMDS_TIMEOUT_IN_SECONDS, headers=headers,
4eb3b8
-            retries=retries, exception_cb=retry_on_url_exc)
4eb3b8
+            retries=retries, exception_cb=exc_cb, infinite=infinite)
4eb3b8
     except Exception as e:
4eb3b8
         # pylint:disable=no-member
4eb3b8
         if isinstance(e, UrlError) and e.code == 400:
4eb3b8
diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
4eb3b8
index d9817d84..c4a8e08d 100644
4eb3b8
--- a/tests/unittests/test_datasource/test_azure.py
4eb3b8
+++ b/tests/unittests/test_datasource/test_azure.py
4eb3b8
@@ -448,7 +448,7 @@ class TestGetMetadataFromIMDS(HttprettyTestCase):
4eb3b8
             "http://169.254.169.254/metadata/instance?api-version="
4eb3b8
             "2019-06-01", exception_cb=mock.ANY,
4eb3b8
             headers=mock.ANY, retries=mock.ANY,
4eb3b8
-            timeout=mock.ANY)
4eb3b8
+            timeout=mock.ANY, infinite=False)
4eb3b8
 
4eb3b8
     @mock.patch(MOCKPATH + 'readurl', autospec=True)
4eb3b8
     @mock.patch(MOCKPATH + 'EphemeralDHCPv4')
4eb3b8
@@ -467,7 +467,7 @@ class TestGetMetadataFromIMDS(HttprettyTestCase):
4eb3b8
             "http://169.254.169.254/metadata/instance/network?api-version="
4eb3b8
             "2019-06-01", exception_cb=mock.ANY,
4eb3b8
             headers=mock.ANY, retries=mock.ANY,
4eb3b8
-            timeout=mock.ANY)
4eb3b8
+            timeout=mock.ANY, infinite=False)
4eb3b8
 
4eb3b8
     @mock.patch(MOCKPATH + 'readurl', autospec=True)
4eb3b8
     @mock.patch(MOCKPATH + 'EphemeralDHCPv4')
4eb3b8
@@ -486,7 +486,7 @@ class TestGetMetadataFromIMDS(HttprettyTestCase):
4eb3b8
             "http://169.254.169.254/metadata/instance?api-version="
4eb3b8
             "2019-06-01", exception_cb=mock.ANY,
4eb3b8
             headers=mock.ANY, retries=mock.ANY,
4eb3b8
-            timeout=mock.ANY)
4eb3b8
+            timeout=mock.ANY, infinite=False)
4eb3b8
 
4eb3b8
     @mock.patch(MOCKPATH + 'readurl', autospec=True)
4eb3b8
     @mock.patch(MOCKPATH + 'EphemeralDHCPv4WithReporting', autospec=True)
4eb3b8
@@ -511,7 +511,7 @@ class TestGetMetadataFromIMDS(HttprettyTestCase):
4eb3b8
         m_readurl.assert_called_with(
4eb3b8
             self.network_md_url, exception_cb=mock.ANY,
4eb3b8
             headers={'Metadata': 'true'}, retries=2,
4eb3b8
-            timeout=dsaz.IMDS_TIMEOUT_IN_SECONDS)
4eb3b8
+            timeout=dsaz.IMDS_TIMEOUT_IN_SECONDS, infinite=False)
4eb3b8
 
4eb3b8
     @mock.patch('cloudinit.url_helper.time.sleep')
4eb3b8
     @mock.patch(MOCKPATH + 'net.is_up', autospec=True)
4eb3b8
@@ -2694,15 +2694,22 @@ class TestPreprovisioningHotAttachNics(CiTestCase):
4eb3b8
 
4eb3b8
         def nic_attach_ret(nl_sock, nics_found):
4eb3b8
             nonlocal m_attach_call_count
4eb3b8
-            if m_attach_call_count == 0:
4eb3b8
-                m_attach_call_count = m_attach_call_count + 1
4eb3b8
+            m_attach_call_count = m_attach_call_count + 1
4eb3b8
+            if m_attach_call_count == 1:
4eb3b8
                 return "eth0"
4eb3b8
-            return "eth1"
4eb3b8
+            elif m_attach_call_count == 2:
4eb3b8
+                return "eth1"
4eb3b8
+            raise RuntimeError("Must have found primary nic by now.")
4eb3b8
+
4eb3b8
+        # Simulate two NICs by adding the same one twice.
4eb3b8
+        md = {
4eb3b8
+            "interface": [
4eb3b8
+                IMDS_NETWORK_METADATA['interface'][0],
4eb3b8
+                IMDS_NETWORK_METADATA['interface'][0]
4eb3b8
+            ]
4eb3b8
+        }
4eb3b8
 
4eb3b8
-        def network_metadata_ret(ifname, retries, type):
4eb3b8
-            # Simulate two NICs by adding the same one twice.
4eb3b8
-            md = IMDS_NETWORK_METADATA
4eb3b8
-            md['interface'].append(md['interface'][0])
4eb3b8
+        def network_metadata_ret(ifname, retries, type, exc_cb, infinite):
4eb3b8
             if ifname == "eth0":
4eb3b8
                 return md
4eb3b8
             raise requests.Timeout('Fake connection timeout')
4eb3b8
@@ -2724,6 +2731,72 @@ class TestPreprovisioningHotAttachNics(CiTestCase):
4eb3b8
         self.assertEqual(1, m_imds.call_count)
4eb3b8
         self.assertEqual(2, m_link_up.call_count)
4eb3b8
 
4eb3b8
+    @mock.patch(MOCKPATH + 'DataSourceAzure.get_imds_data_with_api_fallback')
4eb3b8
+    @mock.patch(MOCKPATH + 'EphemeralDHCPv4')
4eb3b8
+    def test_check_if_nic_is_primary_retries_on_failures(
4eb3b8
+            self, m_dhcpv4, m_imds):
4eb3b8
+        """Retry polling for network metadata on all failures except timeout"""
4eb3b8
+        dsa = dsaz.DataSourceAzure({}, distro=None, paths=self.paths)
4eb3b8
+        lease = {
4eb3b8
+            'interface': 'eth9', 'fixed-address': '192.168.2.9',
4eb3b8
+            'routers': '192.168.2.1', 'subnet-mask': '255.255.255.0',
4eb3b8
+            'unknown-245': '624c3620'}
4eb3b8
+
4eb3b8
+        eth0Retries = []
4eb3b8
+        eth1Retries = []
4eb3b8
+        # Simulate two NICs by adding the same one twice.
4eb3b8
+        md = {
4eb3b8
+            "interface": [
4eb3b8
+                IMDS_NETWORK_METADATA['interface'][0],
4eb3b8
+                IMDS_NETWORK_METADATA['interface'][0]
4eb3b8
+            ]
4eb3b8
+        }
4eb3b8
+
4eb3b8
+        def network_metadata_ret(ifname, retries, type, exc_cb, infinite):
4eb3b8
+            nonlocal eth0Retries, eth1Retries
4eb3b8
+
4eb3b8
+            # Simulate readurl functionality with retries and
4eb3b8
+            # exception callbacks so that the callback logic can be
4eb3b8
+            # validated.
4eb3b8
+            if ifname == "eth0":
4eb3b8
+                cause = requests.HTTPError()
4eb3b8
+                for _ in range(0, 15):
4eb3b8
+                    error = url_helper.UrlError(cause=cause, code=410)
4eb3b8
+                    eth0Retries.append(exc_cb("No goal state.", error))
4eb3b8
+            else:
4eb3b8
+                cause = requests.Timeout('Fake connection timeout')
4eb3b8
+                for _ in range(0, 10):
4eb3b8
+                    error = url_helper.UrlError(cause=cause)
4eb3b8
+                    eth1Retries.append(exc_cb("Connection timeout", error))
4eb3b8
+                # Should stop retrying after 10 retries
4eb3b8
+                eth1Retries.append(exc_cb("Connection timeout", error))
4eb3b8
+                raise cause
4eb3b8
+            return md
4eb3b8
+
4eb3b8
+        m_imds.side_effect = network_metadata_ret
4eb3b8
+
4eb3b8
+        dhcp_ctx = mock.MagicMock(lease=lease)
4eb3b8
+        dhcp_ctx.obtain_lease.return_value = lease
4eb3b8
+        m_dhcpv4.return_value = dhcp_ctx
4eb3b8
+
4eb3b8
+        is_primary, expected_nic_count = dsa._check_if_nic_is_primary("eth0")
4eb3b8
+        self.assertEqual(True, is_primary)
4eb3b8
+        self.assertEqual(2, expected_nic_count)
4eb3b8
+
4eb3b8
+        # All Eth0 errors are non-timeout errors. So we should have been
4eb3b8
+        # retrying indefinitely until success.
4eb3b8
+        for i in eth0Retries:
4eb3b8
+            self.assertTrue(i)
4eb3b8
+
4eb3b8
+        is_primary, expected_nic_count = dsa._check_if_nic_is_primary("eth1")
4eb3b8
+        self.assertEqual(False, is_primary)
4eb3b8
+
4eb3b8
+        # All Eth1 errors are timeout errors. Retry happens for a max of 10 and
4eb3b8
+        # then we should have moved on assuming it is not the primary nic.
4eb3b8
+        for i in range(0, 10):
4eb3b8
+            self.assertTrue(eth1Retries[i])
4eb3b8
+        self.assertFalse(eth1Retries[10])
4eb3b8
+
4eb3b8
     @mock.patch('cloudinit.distros.networking.LinuxNetworking.try_set_link_up')
4eb3b8
     def test_wait_for_link_up_returns_if_already_up(
4eb3b8
             self, m_is_link_up):
4eb3b8
-- 
4eb3b8
2.27.0
4eb3b8