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