Repository: Azure/WALinuxAgent
Pull request #1229 (2018-06-22 21:28:47)

Log network interface settings at startup and when they change.


Patch:
diff --git a/azurelinuxagent/common/osutil/default.py b/azurelinuxagent/common/osutil/default.py
index d07919c5..d6a56ff0 100644
--- a/azurelinuxagent/common/osutil/default.py
+++ b/azurelinuxagent/common/osutil/default.py
@@ -22,7 +22,6 @@ import datetime
 import errno
 import fcntl
 import glob
-import hashlib
 import multiprocessing
 import os
 import platform
@@ -37,7 +36,6 @@ import time
 import azurelinuxagent.common.logger as logger
 import azurelinuxagent.common.conf as conf
 import azurelinuxagent.common.utils.fileutil as fileutil
-import azurelinuxagent.common.utils.networkutil as networkutil
 import azurelinuxagent.common.utils.shellutil as shellutil
 import azurelinuxagent.common.utils.textutil as textutil
 
@@ -45,6 +43,7 @@ from azurelinuxagent.common.exception import OSUtilError
 from azurelinuxagent.common.future import ustr
 from azurelinuxagent.common.utils.cryptutil import CryptUtil
 from azurelinuxagent.common.utils.flexible_version import FlexibleVersion
+from azurelinuxagent.common.utils.networkutil import RouteEntry, NetworkInterfaceCard
 
 from pwd import getpwall
 
@@ -94,6 +93,7 @@ IOCTL_SIOCGIFFLAGS = 0x8913
 IOCTL_SIOCGIFHWADDR = 0x8927
 IFNAMSIZ = 16
 
+IP_COMMAND_OUTPUT = re.compile('^\d+:\s+(\w+):\s+(.*)$')
 
 class DefaultOSUtil(object):
     def __init__(self):
@@ -769,7 +769,7 @@ class DefaultOSUtil(object):
         Construct a list of network route entries
         :param list(str) proc_net_route: Route table lines, including headers, containing at least one route
         :return: List of network route objects
-        :rtype: list(networkutil.RouteEntry)
+        :rtype: list(RouteEntry)
         """
         idx = 0
         column_index = {}
@@ -793,7 +793,7 @@ class DefaultOSUtil(object):
         for entry in proc_net_route[1:]:
             route = entry.split("\t")
             if len(route) > 0:
-                route_obj = networkutil.RouteEntry(route[idx_iface], route[idx_dest], route[idx_gw], route[idx_mask],
+                route_obj = RouteEntry(route[idx_iface], route[idx_dest], route[idx_gw], route[idx_mask],
                                                    route[idx_flags], route[idx_metric])
                 route_list.append(route_obj)
         return route_list
@@ -820,7 +820,7 @@ class DefaultOSUtil(object):
 
         :param list(str) route_table: List of text entries from route table, including headers
         :return: a list of network routes
-        :rtype: list(networkutil.RouteEntry)
+        :rtype: list(RouteEntry)
         """
         route_list = []
         count = len(route_table)
@@ -1238,3 +1238,68 @@ class DefaultOSUtil(object):
                     system_cpu = sum(int(i) for i in line.split()[1:7])
                     break
         return system_cpu
+
+    def get_NIC_state(self):
+        """
+        Capture NIC state (IPv4 and IPv6 addresses plus link state).
+
+        :return: Dictionary of NIC state objects, with the NIC name as key
+        :rtype: dict(str,NetworkInformationCard)
+        """
+        state = {}
+
+        status, output = shellutil.run_get_output("ip -a -d -o link")
+        """
+        1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000\    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 promiscuity 0 addrgenmode eui64
+        2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000\    link/ether 00:0d:3a:30:c3:5a brd ff:ff:ff:ff:ff:ff promiscuity 0 addrgenmode eui64
+        3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default \    link/ether 02:42:b5:d5:00:1d brd ff:ff:ff:ff:ff:ff promiscuity 0 \    bridge forward_delay 1500 hello_time 200 max_age 2000 ageing_time 30000 stp_state 0 priority 32768 vlan_filtering 0 vlan_protocol 802.1Q addrgenmode eui64
+
+        """
+        if status != 0:
+            logger.periodic(logger.EVERY_DAY, "Failed to fetch NIC link info; status {0}".format(status))
+            return {}
+
+        for entry in output.splitlines():
+            result = IP_COMMAND_OUTPUT.match(entry)
+            if result:
+                name = result.group(1)
+                state[name] = NetworkInterfaceCard(name, result.group(2))
+
+        self._update_nic_state(state, "ip -4 -a -d -o address", NetworkInterfaceCard.add_ipv4, "an IPv4 address")
+        """
+        1: lo    inet 127.0.0.1/8 scope host lo\       valid_lft forever preferred_lft forever
+        2: eth0    inet 10.145.187.220/26 brd 10.145.187.255 scope global eth0\       valid_lft forever preferred_lft forever
+        3: docker0    inet 192.168.43.1/24 brd 192.168.43.255 scope global docker0\       valid_lft forever preferred_lft forever
+        """
+
+        self._update_nic_state(state, "ip -6 -a -d -o address", NetworkInterfaceCard.add_ipv6, "an IPv6 address")
+        """
+        1: lo    inet6 ::1/128 scope host \       valid_lft forever preferred_lft forever
+        2: eth0    inet6 fe80::20d:3aff:fe30:c35a/64 scope link \       valid_lft forever preferred_lft forever
+        """
+
+        return state
+
+    def _update_nic_state(self, state, ip_command, handler, description):
+        """
+        Update the state of NICs based on the output of a specified ip subcommand.
+
+        :param dict(str, NetworkInterfaceCard) state: Dictionary of NIC state objects
+        :param str ip_command: The ip command to run
+        :param handler: A method on the NetworkInterfaceCard class
+        :param str description: Description of the particular information being added to the state
+        """
+        status, output = shellutil.run_get_output(ip_command)
+        if status != 0:
+            logger.periodic(logger.EVERY_DAY, "Command '{0} returned status {1}".format(ip_command, status))
+            return
+
+        for entry in output.splitlines():
+            result = IP_COMMAND_OUTPUT.match(entry)
+            if result:
+                interface_name = result.group(1)
+                if interface_name in state:
+                    handler(state[interface_name], result.group(2))
+                else:
+                    logger.periodic(logger.EVERY_DAY,
+                                    "Interface {0} has {1} but no link state".format(interface_name, description))
diff --git a/azurelinuxagent/common/utils/networkutil.py b/azurelinuxagent/common/utils/networkutil.py
index 2c086711..68ba08ac 100644
--- a/azurelinuxagent/common/utils/networkutil.py
+++ b/azurelinuxagent/common/utils/networkutil.py
@@ -61,3 +61,35 @@ class RouteEntry(object):
     def __repr__(self):
         return 'RouteEntry("{0}", "{1}", "{2}", "{3}", "{4:#04x}", "{5}")'\
             .format(self.interface, self.destination, self.gateway, self.mask, self.flags, self.metric)
+
+
+class NetworkInterfaceCard:
+    def __init__(self, name, link_info):
+        self.name = name
+        self.ipv4 = set()
+        self.ipv6 = set()
+        self.link = link_info
+
+    def add_ipv4(self, info):
+        self.ipv4.add(info)
+
+    def add_ipv6(self, info):
+        self.ipv6.add(info)
+
+    def __eq__(self, other):
+        return self.link == other.link and \
+               self.ipv4 == other.ipv4 and \
+               self.ipv6 == other.ipv6
+
+    @staticmethod
+    def _json_array(items):
+        return "[{0}]".format(",".join(['"{0}"'.format(x) for x in sorted(items)]))
+
+    def __str__(self):
+        entries = ['"name": "{0}"'.format(self.name),
+                   '"link": "{0}"'.format(self.link)]
+        if len(self.ipv4) > 0:
+            entries.append('"ipv4": {0}'.format(self._json_array(self.ipv4)))
+        if len(self.ipv6) > 0:
+            entries.append('"ipv6": {0}'.format(self._json_array(self.ipv6)))
+        return "{{ {0} }}".format(", ".join(entries))
diff --git a/azurelinuxagent/ga/monitor.py b/azurelinuxagent/ga/monitor.py
index b7e7998a..5c16ffda 100644
--- a/azurelinuxagent/ga/monitor.py
+++ b/azurelinuxagent/ga/monitor.py
@@ -120,6 +120,7 @@ class MonitorHandler(object):
         self.protocol = None
         self.health_service = None
         self.last_route_table_hash = b''
+        self.last_nic_state = {}
 
         self.counter = 0
         self.sysinfo = []
@@ -263,7 +264,7 @@ class MonitorHandler(object):
             self.collect_and_send_events()
             self.send_host_plugin_heartbeat()
             self.send_imds_heartbeat()
-            self.log_route_table()
+            self.log_altered_network_configuration()
             time.sleep(min_delta)
 
     def add_sysinfo(self, event):
@@ -420,10 +421,19 @@ class MonitorHandler(object):
 
             self.last_cgroup_telemetry = datetime.datetime.utcnow()
 
-    def log_route_table(self):
+    def log_altered_network_configuration(self):
+        """
+        Check various pieces of network configuration and, if altered since the last check, log the new state.
+        """
         raw_route_list = self.osutil.read_route_table()
         digest = hash_strings(raw_route_list)
         if digest != self.last_route_table_hash:
             self.last_route_table_hash = digest
             route_list = self.osutil.get_list_of_routes(raw_route_list)
             logger.info("Route table: [{0}]".format(",".join(map(networkutil.RouteEntry.to_json, route_list))))
+
+        nic_state = self.osutil.get_NIC_state()
+        if nic_state != self.last_nic_state:
+            description = "Initial" if self.last_nic_state == {} else "Updated"
+            logger.info("{0} NIC state: [{1}]".format(description, ", ".join(map(str, nic_state.values()))))
+            self.last_nic_state = nic_state


Test patch:
diff --git a/tests/common/osutil/test_default.py b/tests/common/osutil/test_default.py
index 7b2645a6..dd74e985 100644
--- a/tests/common/osutil/test_default.py
+++ b/tests/common/osutil/test_default.py
@@ -35,6 +35,10 @@ def fake_is_loopback(_, iface):
     return iface.startswith('lo')
 
 
+def running_under_travis():
+    return 'TRAVIS' in os.environ and os.environ['TRAVIS'] == 'true'
+
+
 class TestOSUtil(AgentTestCase):
     def test_restart(self):
         # setup
@@ -850,6 +854,17 @@ Chain OUTPUT (policy ACCEPT 104 packets, 43628 bytes)
         self.assertTrue(mock_run.call_count == 1)
         self.assertTrue(mock_output.call_count == 1)
 
+    @skip_if_predicate_true(running_under_travis, "The ip command isn't available in Travis")
+    def test_get_NIC_state(self):
+        state = osutil.DefaultOSUtil().get_NIC_state()
+        self.assertNotEqual(state, {})
+        self.assertGreater(len(state.keys()), 1)
+
+        another_state = osutil.DefaultOSUtil().get_NIC_state()
+        name = list(another_state.keys())[0]
+        another_state[name].add_ipv4("xyzzy")
+        self.assertNotEqual(state, another_state)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/utils/test_network_util.py b/tests/utils/test_network_util.py
index d6dc1ce3..8dfefa22 100644
--- a/tests/utils/test_network_util.py
+++ b/tests/utils/test_network_util.py
@@ -15,15 +15,8 @@
 # Requires Python 2.6+ and Openssl 1.0+
 #
 
-import errno
-from socket import htonl
-
-
-import azurelinuxagent.common.utils.fileutil as fileutil
 import azurelinuxagent.common.utils.networkutil as networkutil
 
-
-from azurelinuxagent.common.future import ustr
 from tests.tools import *
 
 
@@ -43,3 +36,27 @@ class TestNetworkOperations(AgentTestCase):
 
         self.assertEqual(str(entry), expected)
         self.assertEqual(entry.to_json(), expected_json)
+
+    def test_nic_link_only(self):
+        nic = networkutil.NetworkInterfaceCard("test0", "link info")
+        self.assertEqual(str(nic), '{ "name": "test0", "link": "link info" }')
+
+    def test_nic_ipv4(self):
+        nic = networkutil.NetworkInterfaceCard("test0", "link info")
+        nic.add_ipv4("ipv4-1")
+        self.assertEqual(str(nic), '{ "name": "test0", "link": "link info", "ipv4": ["ipv4-1"] }')
+        nic.add_ipv4("ipv4-2")
+        self.assertEqual(str(nic), '{ "name": "test0", "link": "link info", "ipv4": ["ipv4-1","ipv4-2"] }')
+
+    def test_nic_ipv6(self):
+        nic = networkutil.NetworkInterfaceCard("test0", "link info")
+        nic.add_ipv6("ipv6-1")
+        self.assertEqual(str(nic), '{ "name": "test0", "link": "link info", "ipv6": ["ipv6-1"] }')
+        nic.add_ipv6("ipv6-2")
+        self.assertEqual(str(nic), '{ "name": "test0", "link": "link info", "ipv6": ["ipv6-1","ipv6-2"] }')
+
+    def test_nic_ordinary(self):
+        nic = networkutil.NetworkInterfaceCard("test0", "link INFO")
+        nic.add_ipv6("ipv6-1")
+        nic.add_ipv4("ipv4-1")
+        self.assertEqual(str(nic), '{ "name": "test0", "link": "link INFO", "ipv4": ["ipv4-1"], "ipv6": ["ipv6-1"] }')