← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~smoser/cloud-init:azure_run_local into cloud-init:master

 

Scott Moser has proposed merging ~smoser/cloud-init:azure_run_local into cloud-init:master.

Commit message:
place holder

Requested reviews:
  cloud-init commiters (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~smoser/cloud-init/+git/cloud-init/+merge/326373

building on Ryan's changes at
https://code.launchpad.net/~raharper/cloud-init/+git/cloud-init/+merge/326099



-- 
Your team cloud-init commiters is requested to review the proposed merge of ~smoser/cloud-init:azure_run_local into cloud-init:master.
diff --git a/cloudinit/cmd/main.py b/cloudinit/cmd/main.py
index ce3c10d..35d9a55 100644
--- a/cloudinit/cmd/main.py
+++ b/cloudinit/cmd/main.py
@@ -372,6 +372,7 @@ def main_init(name, args):
             LOG.debug("[%s] %s is in local mode, will apply init modules now.",
                       mode, init.datasource)
 
+    init.setup_datasource()
     # update fully realizes user-data (pulling in #include if necessary)
     init.update()
     # Stage 7
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index 65accbb..cba991a 100644
--- a/cloudinit/net/__init__.py
+++ b/cloudinit/net/__init__.py
@@ -97,6 +97,10 @@ def is_bridge(devname):
     return os.path.exists(sys_dev_path(devname, "bridge"))
 
 
+def is_bond(devname):
+    return os.path.exists(sys_dev_path(devname, "bonding"))
+
+
 def is_vlan(devname):
     uevent = str(read_sys_net_safe(devname, "uevent"))
     return 'DEVTYPE=vlan' in uevent.splitlines()
@@ -124,6 +128,26 @@ def is_present(devname):
     return os.path.exists(sys_dev_path(devname))
 
 
+def device_driver(devname):
+    """Return the device driver for net device named 'devname'."""
+    driver = None
+    driver_path = sys_dev_path(devname, "device/driver")
+    # driver is a symlink to the driver *dir*
+    if os.path.islink(driver_path):
+        driver = os.path.basename(os.readlink(driver_path))
+
+    return driver
+
+
+def device_devid(devname):
+    """Return the device id string for net device named 'devname'."""
+    dev_id = read_sys_net_safe(devname, "device/device")
+    if dev_id is False:
+        return None
+
+    return dev_id
+
+
 def get_devicelist():
     return os.listdir(SYS_CLASS_NET)
 
@@ -138,12 +162,21 @@ def is_disabled_cfg(cfg):
     return cfg.get('config') == "disabled"
 
 
-def generate_fallback_config():
+def generate_fallback_config(blacklist_drivers=None, config_driver=None):
     """Determine which attached net dev is most likely to have a connection and
        generate network state to run dhcp on that interface"""
+
+    if not config_driver:
+        config_driver = False
+
+    if not blacklist_drivers:
+        blacklist_drivers = []
+
     # get list of interfaces that could have connections
     invalid_interfaces = set(['lo'])
-    potential_interfaces = set(get_devicelist())
+    potential_interfaces = set([device for device in get_devicelist()
+                                if device_driver(device) not in
+                                blacklist_drivers])
     potential_interfaces = potential_interfaces.difference(invalid_interfaces)
     # sort into interfaces with carrier, interfaces which could have carrier,
     # and ignore interfaces that are definitely disconnected
@@ -155,6 +188,9 @@ def generate_fallback_config():
         if is_bridge(interface):
             # skip any bridges
             continue
+        if is_bond(interface):
+            # skip any bonds
+            continue
         carrier = read_sys_net_int(interface, 'carrier')
         if carrier:
             connected.append(interface)
@@ -194,9 +230,18 @@ def generate_fallback_config():
             break
     if target_mac and target_name:
         nconf = {'config': [], 'version': 1}
-        nconf['config'].append(
-            {'type': 'physical', 'name': target_name,
-             'mac_address': target_mac, 'subnets': [{'type': 'dhcp'}]})
+        cfg = {'type': 'physical', 'name': target_name,
+               'mac_address': target_mac, 'subnets': [{'type': 'dhcp'}]}
+        # inject the device driver name, dev_id into config if enabled and
+        # device has a valid device driver value
+        if config_driver:
+            driver = device_driver(target_name)
+            if driver:
+                cfg['params'] = {
+                    'driver': driver,
+                    'device_id': device_devid(target_name),
+                }
+        nconf['config'].append(cfg)
         return nconf
     else:
         # can't read any interfaces addresses (or there are none); give up
@@ -217,10 +262,16 @@ def apply_network_config_names(netcfg, strict_present=True, strict_busy=True):
         if ent.get('type') != 'physical':
             continue
         mac = ent.get('mac_address')
-        name = ent.get('name')
         if not mac:
             continue
-        renames.append([mac, name])
+        name = ent.get('name')
+        driver = ent.get('params', {}).get('driver')
+        device_id = ent.get('params', {}).get('device_id')
+        if not driver:
+            driver = device_driver(name)
+        if not device_id:
+            device_id = device_devid(name)
+        renames.append([mac, name, driver, device_id])
 
     return _rename_interfaces(renames)
 
@@ -245,15 +296,27 @@ def _get_current_rename_info(check_downable=True):
     """Collect information necessary for rename_interfaces.
 
     returns a dictionary by mac address like:
-       {mac:
-         {'name': name
-          'up': boolean: is_up(name),
+       {name:
+         {
           'downable': None or boolean indicating that the
-                      device has only automatically assigned ip addrs.}}
+                      device has only automatically assigned ip addrs.
+          'device_id': Device id value (if it has one)
+          'driver': Device driver (if it has one)
+          'mac': mac address
+          'name': name
+          'up': boolean: is_up(name)
+         }}
     """
-    bymac = {}
-    for mac, name in get_interfaces_by_mac().items():
-        bymac[mac] = {'name': name, 'up': is_up(name), 'downable': None}
+    cur_info = {}
+    for (name, mac, driver, device_id) in get_interfaces():
+        cur_info[name] = {
+            'downable': None,
+            'device_id': device_id,
+            'driver': driver,
+            'mac': mac,
+            'name': name,
+            'up': is_up(name),
+        }
 
     if check_downable:
         nmatch = re.compile(r"[0-9]+:\s+(\w+)[@:]")
@@ -265,11 +328,11 @@ def _get_current_rename_info(check_downable=True):
         for bytes_out in (ipv6, ipv4):
             nics_with_addresses.update(nmatch.findall(bytes_out))
 
-        for d in bymac.values():
+        for d in cur_info.values():
             d['downable'] = (d['up'] is False or
                              d['name'] not in nics_with_addresses)
 
-    return bymac
+    return cur_info
 
 
 def _rename_interfaces(renames, strict_present=True, strict_busy=True,
@@ -282,15 +345,15 @@ def _rename_interfaces(renames, strict_present=True, strict_busy=True,
     if current_info is None:
         current_info = _get_current_rename_info()
 
-    cur_bymac = {}
-    for mac, data in current_info.items():
+    cur_info = {}
+    for name, data in current_info.items():
         cur = data.copy()
-        cur['mac'] = mac
-        cur_bymac[mac] = cur
+        cur['name'] = name
+        cur_info[name] = cur
 
     def update_byname(bymac):
         return dict((data['name'], data)
-                    for data in bymac.values())
+                    for data in cur_info.values())
 
     def rename(cur, new):
         util.subp(["ip", "link", "set", cur, "name", new], capture=True)
@@ -304,14 +367,48 @@ def _rename_interfaces(renames, strict_present=True, strict_busy=True,
     ops = []
     errors = []
     ups = []
-    cur_byname = update_byname(cur_bymac)
+    cur_byname = update_byname(cur_info)
     tmpname_fmt = "cirename%d"
     tmpi = -1
 
-    for mac, new_name in renames:
-        cur = cur_bymac.get(mac, {})
-        cur_name = cur.get('name')
+    def entry_match(data, mac, driver, device_id):
+        """match if set and in data"""
+        if mac and driver and device_id:
+            return (data['mac'] == mac and
+                    data['driver'] == driver and
+                    data['device_id'] == device_id)
+        elif mac and driver:
+            return (data['mac'] == mac and
+                    data['driver'] == driver)
+        elif mac:
+            return (data['mac'] == mac)
+
+        return False
+
+    def find_entry(mac, driver, device_id):
+        match = [data for data in cur_info.values()
+                 if entry_match(data, mac, driver, device_id)]
+        if len(match):
+            if len(match) > 1:
+                msg = ('Failed to match a single device. Matched devices "%s"'
+                       ' with search values "(mac:%s driver:%s device_id:%s)"'
+                       % (match, mac, driver, device_id))
+                raise ValueError(msg)
+            return match[0]
+
+        return None
+
+    for mac, new_name, driver, device_id in renames:
         cur_ops = []
+        cur = find_entry(mac, driver, device_id)
+        if not cur:
+            if strict_present:
+                errors.append(
+                    "[nic not present] Cannot rename mac=%s to %s"
+                    ", not available." % (mac, new_name))
+            continue
+
+        cur_name = cur.get('name')
         if cur_name == new_name:
             # nothing to do
             continue
@@ -351,13 +448,13 @@ def _rename_interfaces(renames, strict_present=True, strict_busy=True,
 
             cur_ops.append(("rename", mac, new_name, (new_name, tmp_name)))
             target['name'] = tmp_name
-            cur_byname = update_byname(cur_bymac)
+            cur_byname = update_byname(cur_info)
             if target['up']:
                 ups.append(("up", mac, new_name, (tmp_name,)))
 
         cur_ops.append(("rename", mac, new_name, (cur['name'], new_name)))
         cur['name'] = new_name
-        cur_byname = update_byname(cur_bymac)
+        cur_byname = update_byname(cur_info)
         ops += cur_ops
 
     opmap = {'rename': rename, 'down': down, 'up': up}
@@ -426,6 +523,36 @@ def get_interfaces_by_mac():
     return ret
 
 
+def get_interfaces():
+    """Return list of interface tuples (name, mac, driver, device_id)
+
+    Bridges and any devices that have a 'stolen' mac are excluded."""
+    try:
+        devs = get_devicelist()
+    except OSError as e:
+        if e.errno == errno.ENOENT:
+            devs = []
+        else:
+            raise
+    ret = []
+    empty_mac = '00:00:00:00:00:00'
+    for name in devs:
+        if not interface_has_own_mac(name):
+            continue
+        if is_bridge(name):
+            continue
+        if is_vlan(name):
+            continue
+        mac = get_interface_mac(name)
+        # some devices may not have a mac (tun0)
+        if not mac:
+            continue
+        if mac == empty_mac and name != 'lo':
+            continue
+        ret.append((name, mac, device_driver(name), device_devid(name)))
+    return ret
+
+
 class RendererNotFoundError(RuntimeError):
     pass
 
diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py
index 98ce01e..b707146 100644
--- a/cloudinit/net/eni.py
+++ b/cloudinit/net/eni.py
@@ -72,6 +72,8 @@ def _iface_add_attrs(iface, index):
     content = []
     ignore_map = [
         'control',
+        'device_id',
+        'driver',
         'index',
         'inet',
         'mode',
diff --git a/cloudinit/net/renderer.py b/cloudinit/net/renderer.py
index c68658d..bba139e 100644
--- a/cloudinit/net/renderer.py
+++ b/cloudinit/net/renderer.py
@@ -34,8 +34,10 @@ class Renderer(object):
         for iface in network_state.iter_interfaces(filter_by_physical):
             # for physical interfaces write out a persist net udev rule
             if 'name' in iface and iface.get('mac_address'):
+                driver = iface.get('driver', None)
                 content.write(generate_udev_rule(iface['name'],
-                                                 iface['mac_address']))
+                                                 iface['mac_address'],
+                                                 driver=driver))
         return content.getvalue()
 
     @abc.abstractmethod
diff --git a/cloudinit/net/udev.py b/cloudinit/net/udev.py
index fd2fd8c..58c0a70 100644
--- a/cloudinit/net/udev.py
+++ b/cloudinit/net/udev.py
@@ -23,7 +23,7 @@ def compose_udev_setting(key, value):
     return '%s="%s"' % (key, value)
 
 
-def generate_udev_rule(interface, mac):
+def generate_udev_rule(interface, mac, driver=None):
     """Return a udev rule to set the name of network interface with `mac`.
 
     The rule ends up as a single line looking something like:
@@ -31,10 +31,13 @@ def generate_udev_rule(interface, mac):
     SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*",
     ATTR{address}="ff:ee:dd:cc:bb:aa", NAME="eth0"
     """
+    if not driver:
+        driver = '?*'
+
     rule = ', '.join([
         compose_udev_equality('SUBSYSTEM', 'net'),
         compose_udev_equality('ACTION', 'add'),
-        compose_udev_equality('DRIVERS', '?*'),
+        compose_udev_equality('DRIVERS', driver),
         compose_udev_attr_equality('address', mac),
         compose_udev_setting('NAME', interface),
     ])
diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py
index 4fe0d63..d9132d0 100644
--- a/cloudinit/sources/DataSourceAzure.py
+++ b/cloudinit/sources/DataSourceAzure.py
@@ -16,6 +16,7 @@ from xml.dom import minidom
 import xml.etree.ElementTree as ET
 
 from cloudinit import log as logging
+from cloudinit import net
 from cloudinit import sources
 from cloudinit.sources.helpers.azure import get_metadata_from_fabric
 from cloudinit import util
@@ -245,7 +246,9 @@ def temporary_hostname(temp_hostname, cfg, hostname_command='hostname'):
         set_hostname(previous_hostname, hostname_command)
 
 
-class DataSourceAzureNet(sources.DataSource):
+class DataSourceAzure(sources.DataSource):
+    _negotiated = False
+
     def __init__(self, sys_cfg, distro, paths):
         sources.DataSource.__init__(self, sys_cfg, distro, paths)
         self.seed_dir = os.path.join(paths.seed_dir, 'azure')
@@ -255,6 +258,7 @@ class DataSourceAzureNet(sources.DataSource):
             util.get_cfg_by_path(sys_cfg, DS_CFG_PATH, {}),
             BUILTIN_DS_CONFIG])
         self.dhclient_lease_file = self.ds_cfg.get('dhclient_lease_file')
+        self._network_config = None
 
     def __str__(self):
         root = sources.DataSource.__str__(self)
@@ -331,6 +335,7 @@ class DataSourceAzureNet(sources.DataSource):
         if asset_tag != AZURE_CHASSIS_ASSET_TAG:
             LOG.debug("Non-Azure DMI asset tag '%s' discovered.", asset_tag)
             return False
+
         ddir = self.ds_cfg['data_dir']
 
         candidates = [self.seed_dir]
@@ -375,13 +380,14 @@ class DataSourceAzureNet(sources.DataSource):
             LOG.debug("using files cached in %s", ddir)
 
         # azure / hyper-v provides random data here
+        # TODO. find the seed on FreeBSD platform
+        # now update ds_cfg to reflect contents pass in config
         if not util.is_FreeBSD():
             seed = util.load_file("/sys/firmware/acpi/tables/OEM0",
                                   quiet=True, decode=False)
             if seed:
                 self.metadata['random_seed'] = seed
-        # TODO. find the seed on FreeBSD platform
-        # now update ds_cfg to reflect contents pass in config
+
         user_ds_cfg = util.get_cfg_by_path(self.cfg, DS_CFG_PATH, {})
         self.ds_cfg = util.mergemanydict([user_ds_cfg, self.ds_cfg])
 
@@ -389,6 +395,33 @@ class DataSourceAzureNet(sources.DataSource):
         # the directory to be protected.
         write_files(ddir, files, dirmode=0o700)
 
+        self.metadata['instance-id'] = util.read_dmi_data('system-uuid')
+
+        return True
+
+    def device_name_to_device(self, name):
+        return self.ds_cfg['disk_aliases'].get(name)
+
+    def get_config_obj(self):
+        return self.cfg
+
+    def check_instance_id(self, sys_cfg):
+        # quickly (local check only) if self.instance_id is still valid
+        return sources.instance_id_matches_system_uuid(self.get_instance_id())
+
+    def setup(self, is_new_instance):
+        if self._negotiated is False:
+            LOG.debug("negotiating for %s", self.get_instance_id())
+            fabric_data = self._negotiate()
+            LOG.debug("negotiating returned %s", fabric_data)
+            if fabric_data:
+                self.metadata.update(fabric_data)
+            self._negotiated = True
+        else:
+            LOG.debug("negotiating already done for %s",
+                      self.get_instance_id())
+
+    def _negotiate(self):
         if self.ds_cfg['agent_command'] == AGENT_START_BUILTIN:
             self.bounce_network_with_azure_hostname()
 
@@ -398,31 +431,64 @@ class DataSourceAzureNet(sources.DataSource):
         else:
             metadata_func = self.get_metadata_from_agent
 
+        LOG.debug("negotiating with fabric via %s",
+                  self.ds_cfg['agent_command'])
         try:
             fabric_data = metadata_func()
         except Exception as exc:
             LOG.info("Error communicating with Azure fabric; assume we aren't"
                      " on Azure.", exc_info=True)
             return False
-        self.metadata['instance-id'] = util.read_dmi_data('system-uuid')
-        self.metadata.update(fabric_data)
-
-        return True
-
-    def device_name_to_device(self, name):
-        return self.ds_cfg['disk_aliases'].get(name)
 
-    def get_config_obj(self):
-        return self.cfg
-
-    def check_instance_id(self, sys_cfg):
-        # quickly (local check only) if self.instance_id is still valid
-        return sources.instance_id_matches_system_uuid(self.get_instance_id())
+        return fabric_data
 
     def activate(self, cfg, is_new_instance):
         address_ephemeral_resize(is_new_instance=is_new_instance)
         return
 
+    @property
+    def network_config(self):
+        """Generate a network config like net.generate_fallback_network() with
+           the following execptions.
+
+           1. Probe the drivers of the net-devices present and inject them in
+              the network configuration under params: driver: <driver> value
+           2. If the driver value is 'mlx4_core', the control mode should be
+              set to manual.  The device will be later used to build a bond,
+              for now we want to ensure the device gets named but does not
+              break any network configuration
+        """
+        blacklist = ['mlx4_core']
+        if not self._network_config:
+            LOG.debug('Azure: generating fallback configuration')
+            # generate a network config, blacklist picking any mlx4_core devs
+            netconfig = net.generate_fallback_config(
+                blacklist_drivers=blacklist, config_driver=True)
+
+            # if we have any blacklisted devices, update the network_config to
+            # include the device, mac, and driver values, but with no ip
+            # config; this ensures udev rules are generated but won't affect
+            # ip configuration
+            bl_found = 0
+            for bl_dev in [dev for dev in net.get_devicelist()
+                           if net.device_driver(dev) in blacklist]:
+                bl_found += 1
+                cfg = {
+                    'type': 'physical',
+                    'name': 'vf%d' % bl_found,
+                    'mac_address': net.get_interface_mac(bl_dev),
+                    'params': {
+                        'driver': net.device_driver(bl_dev),
+                        'device_id': net.device_devid(bl_dev),
+                    },
+                }
+                netconfig['config'].append(cfg)
+
+            # switch to network mode after generating a config
+            self._network_config = netconfig
+
+        return self._network_config
+
 
 def _partitions_on_device(devpath, maxnum=16):
     # return a list of tuples (ptnum, path) for each part on devpath
@@ -849,9 +915,12 @@ class NonAzureDataSource(Exception):
     pass
 
 
+# Legacy: Must be present in case we load an old pkl object
+DataSourceAzureNet = DataSourceAzure
+
 # Used to match classes to dependencies
 datasources = [
-    (DataSourceAzureNet, (sources.DEP_FILESYSTEM, sources.DEP_NETWORK)),
+    (DataSourceAzure, (sources.DEP_FILESYSTEM, )),
 ]
 
 
diff --git a/cloudinit/sources/__init__.py b/cloudinit/sources/__init__.py
index c3ce36d..952caf3 100644
--- a/cloudinit/sources/__init__.py
+++ b/cloudinit/sources/__init__.py
@@ -251,10 +251,23 @@ class DataSource(object):
     def first_instance_boot(self):
         return
 
+    def setup(self, is_new_instance):
+        """setup(is_new_instance)
+
+        This is called before user-data and vendor-data have been processed.
+
+        Unless the datasource has set mode to 'local', then networking
+        per 'fallback' or per 'network_config' will have been written and
+        brought up the OS at this point.
+        """
+        return
+
     def activate(self, cfg, is_new_instance):
         """activate(cfg, is_new_instance)
 
-        This is called before the init_modules will be called.
+        This is called before the init_modules will be called but after
+        the user-data and vendor-data have been fully processed.
+
         The cfg is fully up to date config, it contains a merged view of
            system config, datasource config, user config, vendor config.
         It should be used rather than the sys_cfg passed to __init__.
diff --git a/cloudinit/stages.py b/cloudinit/stages.py
index ad55782..a1c4a51 100644
--- a/cloudinit/stages.py
+++ b/cloudinit/stages.py
@@ -362,6 +362,11 @@ class Init(object):
         self._store_userdata()
         self._store_vendordata()
 
+    def setup_datasource(self):
+        if self.datasource is None:
+            raise RuntimeError("Datasource is None, cannot setup.")
+        self.datasource.setup(is_new_instance=self.is_new_instance())
+
     def activate_datasource(self):
         if self.datasource is None:
             raise RuntimeError("Datasource is None, cannot activate.")
diff --git a/tests/unittests/test_datasource/test_azure.py b/tests/unittests/test_datasource/test_azure.py
index 7d33daf..2f7c1bb 100644
--- a/tests/unittests/test_datasource/test_azure.py
+++ b/tests/unittests/test_datasource/test_azure.py
@@ -142,6 +142,10 @@ scbus-1 on xpt0 bus 0
         def _invoke_agent(cmd):
             data['agent_invoked'] = cmd
 
+        def _get_dsmodes(config):
+            # sources.DSMODE_NETWORK
+            return 'net'
+
         def _wait_for_files(flist, _maxwait=None, _naplen=None):
             data['waited'] = flist
             return []
@@ -171,6 +175,7 @@ scbus-1 on xpt0 bus 0
         self.apply_patches([
             (dsaz, 'list_possible_azure_ds_devs', dsdevs),
             (dsaz, 'invoke_agent', _invoke_agent),
+            (dsaz, 'get_dsmodes', _get_dsmodes),
             (dsaz, 'wait_for_files', _wait_for_files),
             (dsaz, 'pubkeys_from_crt_files', _pubkeys_from_crt_files),
             (dsaz, 'perform_hostname_bounce', mock.MagicMock()),
@@ -181,7 +186,7 @@ scbus-1 on xpt0 bus 0
                 side_effect=_dmi_mocks)),
         ])
 
-        dsrc = dsaz.DataSourceAzureNet(
+        dsrc = dsaz.DataSourceAzure(
             data.get('sys_cfg', {}), distro=None, paths=self.paths)
         if agent_command is not None:
             dsrc.ds_cfg['agent_command'] = agent_command
@@ -259,7 +264,7 @@ fdescfs            /dev/fd          fdescfs rw              0 0
         # Return a non-matching asset tag value
         nonazure_tag = dsaz.AZURE_CHASSIS_ASSET_TAG + 'X'
         m_read_dmi_data.return_value = nonazure_tag
-        dsrc = dsaz.DataSourceAzureNet(
+        dsrc = dsaz.DataSourceAzure(
             {}, distro=None, paths=self.paths)
         self.assertFalse(dsrc.get_data())
         self.assertEqual(
@@ -554,6 +559,84 @@ fdescfs            /dev/fd          fdescfs rw              0 0
         self.assertEqual(
             [mock.call("/dev/cd0")], m_check_fbsd_cdrom.call_args_list)
 
+    @mock.patch('cloudinit.net.get_interface_mac')
+    @mock.patch('cloudinit.net.get_devicelist')
+    @mock.patch('cloudinit.net.device_driver')
+    @mock.patch('cloudinit.net.generate_fallback_config')
+    def test_network_config(self, mock_fallback, mock_dd,
+                            mock_devlist, mock_get_mac):
+        odata = {'HostName': "myhost", 'UserName': "myuser"}
+        data = {'ovfcontent': construct_valid_ovf_env(data=odata),
+                'sys_cfg': {}}
+
+        fallback_config = {
+            'version': 1,
+            'config': [{
+                'type': 'physical', 'name': 'eth0',
+                'mac_address': '00:11:22:33:44:55',
+                'params': {'driver': 'hv_netsvc'},
+                'subnets': [{'type': 'dhcp'}],
+            }]
+        }
+        mock_fallback.return_value = fallback_config
+
+        mock_devlist.return_value = ['eth0']
+        mock_dd.return_value = ['hv_netsvc']
+        mock_get_mac.return_value = '00:11:22:33:44:55'
+
+        dsrc = self._get_ds(data)
+        ret = dsrc.get_data()
+        self.assertTrue(ret)
+
+        netconfig = dsrc.network_config
+        self.assertEqual(netconfig, fallback_config)
+        mock_fallback.assert_called_with(blacklist_drivers=['mlx4_core'],
+                                         config_driver=True)
+
+    @mock.patch('cloudinit.net.get_interface_mac')
+    @mock.patch('cloudinit.net.get_devicelist')
+    @mock.patch('cloudinit.net.device_driver')
+    @mock.patch('cloudinit.net.generate_fallback_config')
+    def test_network_config_blacklist(self, mock_fallback, mock_dd,
+                                      mock_devlist, mock_get_mac):
+        odata = {'HostName': "myhost", 'UserName': "myuser"}
+        data = {'ovfcontent': construct_valid_ovf_env(data=odata),
+                'sys_cfg': {}}
+
+        fallback_config = {
+            'version': 1,
+            'config': [{
+                'type': 'physical', 'name': 'eth0',
+                'mac_address': '00:11:22:33:44:55',
+                'params': {'driver': 'hv_netsvc'},
+                'subnets': [{'type': 'dhcp'}],
+            }]
+        }
+        blacklist_config = {
+            'type': 'physical',
+            'name': 'eth1',
+            'mac_address': '00:11:22:33:44:55',
+            'params': {'driver': 'mlx4_core'}
+        }
+        mock_fallback.return_value = fallback_config
+
+        mock_devlist.return_value = ['eth0', 'eth1']
+        mock_dd.side_effect = [
+            'hv_netsvc',  # list composition, skipped
+            'mlx4_core',  # list composition, match
+            'mlx4_core',  # config get driver name
+        ]
+        mock_get_mac.return_value = '00:11:22:33:44:55'
+
+        dsrc = self._get_ds(data)
+        ret = dsrc.get_data()
+        self.assertTrue(ret)
+
+        netconfig = dsrc.network_config
+        expected_config = fallback_config
+        expected_config['config'].append(blacklist_config)
+        self.assertEqual(netconfig, expected_config)
+
 
 class TestAzureBounce(TestCase):
 
@@ -568,6 +651,9 @@ class TestAzureBounce(TestCase):
         self.patches.enter_context(
             mock.patch.object(dsaz, 'get_metadata_from_fabric',
                               mock.MagicMock(return_value={})))
+        self.patches.enter_context(
+            mock.patch.object(dsaz, 'get_dsmodes',
+                              mock.MagicMock(return_value='net')))
 
         def _dmi_mocks(key):
             if key == 'system-uuid':
@@ -603,7 +689,7 @@ class TestAzureBounce(TestCase):
         if ovfcontent is not None:
             populate_dir(os.path.join(self.paths.seed_dir, "azure"),
                          {'ovf-env.xml': ovfcontent})
-        dsrc = dsaz.DataSourceAzureNet(
+        dsrc = dsaz.DataSourceAzure(
             {}, distro=None, paths=self.paths)
         if agent_command is not None:
             dsrc.ds_cfg['agent_command'] = agent_command
@@ -975,4 +1061,12 @@ class TestCanDevBeReformatted(CiTestCase):
         self.assertEqual(False, value)
         self.assertIn("3 or more", msg.lower())
 
+
+class TestAzureNetExists(CiTestCase):
+    def test_azure_net_must_exist_for_legacy_objpkl(self):
+        """DataSourceAzureNet must exist for old obj.pkl files
+           that reference it."""
+        self.assertTrue(hasattr(dsaz, "DataSourceAzureNet"))
+
+
 # vi: ts=4 expandtab
diff --git a/tests/unittests/test_datasource/test_common.py b/tests/unittests/test_datasource/test_common.py
index 7649b9a..2ff1d9d 100644
--- a/tests/unittests/test_datasource/test_common.py
+++ b/tests/unittests/test_datasource/test_common.py
@@ -26,6 +26,7 @@ from cloudinit.sources import DataSourceNone as DSNone
 from .. import helpers as test_helpers
 
 DEFAULT_LOCAL = [
+    Azure.DataSourceAzure,
     CloudSigma.DataSourceCloudSigma,
     ConfigDrive.DataSourceConfigDrive,
     DigitalOcean.DataSourceDigitalOcean,
@@ -38,7 +39,6 @@ DEFAULT_LOCAL = [
 DEFAULT_NETWORK = [
     AliYun.DataSourceAliYun,
     AltCloud.DataSourceAltCloud,
-    Azure.DataSourceAzureNet,
     Bigstep.DataSourceBigstep,
     CloudStack.DataSourceCloudStack,
     DSNone.DataSourceNone,
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
index 8edc0b8..06e8f09 100644
--- a/tests/unittests/test_net.py
+++ b/tests/unittests/test_net.py
@@ -836,38 +836,176 @@ CONFIG_V1_EXPLICIT_LOOPBACK = {
                 'subnets': [{'control': 'auto', 'type': 'loopback'}]},
                ]}
 
+DEFAULT_DEV_ATTRS = {
+    'eth1000': {
+        "bridge": False,
+        "carrier": False,
+        "dormant": False,
+        "operstate": "down",
+        "address": "07-1C-C6-75-A4-BE",
+        "device/driver": None,
+        "device/device": None,
+    }
+}
+
 
 def _setup_test(tmp_dir, mock_get_devicelist, mock_read_sys_net,
-                mock_sys_dev_path):
-    mock_get_devicelist.return_value = ['eth1000']
-    dev_characteristics = {
-        'eth1000': {
-            "bridge": False,
-            "carrier": False,
-            "dormant": False,
-            "operstate": "down",
-            "address": "07-1C-C6-75-A4-BE",
-        }
-    }
+                mock_sys_dev_path, dev_attrs=None):
+    if not dev_attrs:
+        dev_attrs = DEFAULT_DEV_ATTRS
+
+    mock_get_devicelist.return_value = dev_attrs.keys()
 
     def fake_read(devname, path, translate=None,
                   on_enoent=None, on_keyerror=None,
                   on_einval=None):
-        return dev_characteristics[devname][path]
+        return dev_attrs[devname][path]
 
     mock_read_sys_net.side_effect = fake_read
 
     def sys_dev_path(devname, path=""):
-        return tmp_dir + devname + "/" + path
+        return tmp_dir + "/" + devname + "/" + path
 
-    for dev in dev_characteristics:
+    for dev in dev_attrs:
         os.makedirs(os.path.join(tmp_dir, dev))
         with open(os.path.join(tmp_dir, dev, 'operstate'), 'w') as fh:
-            fh.write("down")
+            fh.write(dev_attrs[dev]['operstate'])
+        os.makedirs(os.path.join(tmp_dir, dev, "device"))
+        for key in ['device/driver']:
+            if key in dev_attrs[dev] and dev_attrs[dev][key]:
+                target = dev_attrs[dev][key]
+                link = os.path.join(tmp_dir, dev, key)
+                print('symlink %s -> %s' % (link, target))
+                os.symlink(target, link)
 
     mock_sys_dev_path.side_effect = sys_dev_path
 
 
+class TestGenerateFallbackConfig(CiTestCase):
+
+    @mock.patch("cloudinit.net.sys_dev_path")
+    @mock.patch("cloudinit.net.read_sys_net")
+    @mock.patch("cloudinit.net.get_devicelist")
+    def test_device_driver(self, mock_get_devicelist, mock_read_sys_net,
+                           mock_sys_dev_path):
+        devices = {
+            'eth0': {
+                'bridge': False, 'carrier': False, 'dormant': False,
+                'operstate': 'down', 'address': '00:11:22:33:44:55',
+                'device/driver': 'hv_netsvc', 'device/device': '0x3'},
+            'eth1': {
+                'bridge': False, 'carrier': False, 'dormant': False,
+                'operstate': 'down', 'address': '00:11:22:33:44:55',
+                'device/driver': 'mlx4_core', 'device/device': '0x7'},
+        }
+
+        tmp_dir = self.tmp_dir()
+        _setup_test(tmp_dir, mock_get_devicelist,
+                    mock_read_sys_net, mock_sys_dev_path,
+                    dev_attrs=devices)
+
+        network_cfg = net.generate_fallback_config(config_driver=True)
+        ns = network_state.parse_net_config_data(network_cfg,
+                                                 skip_broken=False)
+
+        render_dir = os.path.join(tmp_dir, "render")
+        os.makedirs(render_dir)
+
+        # don't set rulepath so eni writes them
+        renderer = eni.Renderer(
+            {'eni_path': 'interfaces', 'netrules_path': 'netrules'})
+        renderer.render_network_state(ns, render_dir)
+
+        self.assertTrue(os.path.exists(os.path.join(render_dir,
+                                                    'interfaces')))
+        with open(os.path.join(render_dir, 'interfaces')) as fh:
+            contents = fh.read()
+        print(contents)
+        expected = """
+auto lo
+iface lo inet loopback
+
+auto eth0
+iface eth0 inet dhcp
+"""
+        self.assertEqual(expected.lstrip(), contents.lstrip())
+
+        self.assertTrue(os.path.exists(os.path.join(render_dir, 'netrules')))
+        with open(os.path.join(render_dir, 'netrules')) as fh:
+            contents = fh.read()
+        print(contents)
+        expected_rule = [
+            'SUBSYSTEM=="net"',
+            'ACTION=="add"',
+            'DRIVERS=="hv_netsvc"',
+            'ATTR{address}=="00:11:22:33:44:55"',
+            'NAME="eth0"',
+        ]
+        self.assertEqual(", ".join(expected_rule) + '\n', contents.lstrip())
+
+    @mock.patch("cloudinit.net.sys_dev_path")
+    @mock.patch("cloudinit.net.read_sys_net")
+    @mock.patch("cloudinit.net.get_devicelist")
+    def test_device_driver_blacklist(self, mock_get_devicelist,
+                                     mock_read_sys_net, mock_sys_dev_path):
+        devices = {
+            'eth1': {
+                'bridge': False, 'carrier': False, 'dormant': False,
+                'operstate': 'down', 'address': '00:11:22:33:44:55',
+                'device/driver': 'hv_netsvc', 'device/device': '0x3'},
+            'eth0': {
+                'bridge': False, 'carrier': False, 'dormant': False,
+                'operstate': 'down', 'address': '00:11:22:33:44:55',
+                'device/driver': 'mlx4_core', 'device/device': '0x7'},
+        }
+
+        tmp_dir = self.tmp_dir()
+        _setup_test(tmp_dir, mock_get_devicelist,
+                    mock_read_sys_net, mock_sys_dev_path,
+                    dev_attrs=devices)
+
+        blacklist = ['mlx4_core']
+        network_cfg = net.generate_fallback_config(blacklist_drivers=blacklist,
+                                                   config_driver=True)
+        ns = network_state.parse_net_config_data(network_cfg,
+                                                 skip_broken=False)
+
+        render_dir = os.path.join(tmp_dir, "render")
+        os.makedirs(render_dir)
+
+        # don't set rulepath so eni writes them
+        renderer = eni.Renderer(
+            {'eni_path': 'interfaces', 'netrules_path': 'netrules'})
+        renderer.render_network_state(ns, render_dir)
+
+        self.assertTrue(os.path.exists(os.path.join(render_dir,
+                                                    'interfaces')))
+        with open(os.path.join(render_dir, 'interfaces')) as fh:
+            contents = fh.read()
+        print(contents)
+        expected = """
+auto lo
+iface lo inet loopback
+
+auto eth1
+iface eth1 inet dhcp
+"""
+        self.assertEqual(expected.lstrip(), contents.lstrip())
+
+        self.assertTrue(os.path.exists(os.path.join(render_dir, 'netrules')))
+        with open(os.path.join(render_dir, 'netrules')) as fh:
+            contents = fh.read()
+        print(contents)
+        expected_rule = [
+            'SUBSYSTEM=="net"',
+            'ACTION=="add"',
+            'DRIVERS=="hv_netsvc"',
+            'ATTR{address}=="00:11:22:33:44:55"',
+            'NAME="eth1"',
+        ]
+        self.assertEqual(", ".join(expected_rule) + '\n', contents.lstrip())
+
+
 class TestSysConfigRendering(CiTestCase):
 
     @mock.patch("cloudinit.net.sys_dev_path")
@@ -1560,6 +1698,118 @@ class TestNetRenderers(CiTestCase):
                           priority=['sysconfig', 'eni'])
 
 
+class TestGetInterfaces(CiTestCase):
+    _data = {'bonds': ['bond1'],
+             'bridges': ['bridge1'],
+             'vlans': ['bond1.101'],
+             'own_macs': ['enp0s1', 'enp0s2', 'bridge1-nic', 'bridge1',
+                          'bond1.101', 'lo', 'eth1'],
+             'macs': {'enp0s1': 'aa:aa:aa:aa:aa:01',
+                      'enp0s2': 'aa:aa:aa:aa:aa:02',
+                      'bond1': 'aa:aa:aa:aa:aa:01',
+                      'bond1.101': 'aa:aa:aa:aa:aa:01',
+                      'bridge1': 'aa:aa:aa:aa:aa:03',
+                      'bridge1-nic': 'aa:aa:aa:aa:aa:03',
+                      'lo': '00:00:00:00:00:00',
+                      'greptap0': '00:00:00:00:00:00',
+                      'eth1': 'aa:aa:aa:aa:aa:01',
+                      'tun0': None},
+             'drivers': {'enp0s1': 'virtio_net',
+                         'enp0s2': 'e1000',
+                         'bond1': None,
+                         'bond1.101': None,
+                         'bridge1': None,
+                         'bridge1-nic': None,
+                         'lo': None,
+                         'greptap0': None,
+                         'eth1': 'mlx4_core',
+                         'tun0': None}}
+    data = {}
+
+    def _se_get_devicelist(self):
+        return list(self.data['devices'])
+
+    def _se_device_driver(self, name):
+        return self.data['drivers'][name]
+
+    def _se_device_devid(self, name):
+        return '0x%s' % sorted(list(self.data['drivers'].keys())).index(name)
+
+    def _se_get_interface_mac(self, name):
+        return self.data['macs'][name]
+
+    def _se_is_bridge(self, name):
+        return name in self.data['bridges']
+
+    def _se_is_vlan(self, name):
+        return name in self.data['vlans']
+
+    def _se_interface_has_own_mac(self, name):
+        return name in self.data['own_macs']
+
+    def _mock_setup(self):
+        self.data = copy.deepcopy(self._data)
+        self.data['devices'] = set(list(self.data['macs'].keys()))
+        mocks = ('get_devicelist', 'get_interface_mac', 'is_bridge',
+                 'interface_has_own_mac', 'is_vlan', 'device_driver',
+                 'device_devid')
+        self.mocks = {}
+        for n in mocks:
+            m = mock.patch('cloudinit.net.' + n,
+                           side_effect=getattr(self, '_se_' + n))
+            self.addCleanup(m.stop)
+            self.mocks[n] = m.start()
+
+    def test_gi_includes_duplicate_macs(self):
+        self._mock_setup()
+        ret = net.get_interfaces()
+
+        self.assertIn('enp0s1', self._se_get_devicelist())
+        self.assertIn('eth1', self._se_get_devicelist())
+        found = [ent for ent in ret if 'aa:aa:aa:aa:aa:01' in ent]
+        self.assertEqual(len(found), 2)
+
+    def test_gi_excludes_any_without_mac_address(self):
+        self._mock_setup()
+        ret = net.get_interfaces()
+
+        self.assertIn('tun0', self._se_get_devicelist())
+        found = [ent for ent in ret if 'tun0' in ent]
+        self.assertEqual(len(found), 0)
+
+    def test_gi_excludes_stolen_macs(self):
+        self._mock_setup()
+        ret = net.get_interfaces()
+        self.mocks['interface_has_own_mac'].assert_has_calls(
+            [mock.call('enp0s1'), mock.call('bond1')], any_order=True)
+        expected = [
+            ('enp0s2', 'aa:aa:aa:aa:aa:02', 'e1000', '0x5'),
+            ('enp0s1', 'aa:aa:aa:aa:aa:01', 'virtio_net', '0x4'),
+            ('eth1', 'aa:aa:aa:aa:aa:01', 'mlx4_core', '0x6'),
+            ('lo', '00:00:00:00:00:00', None, '0x8'),
+            ('bridge1-nic', 'aa:aa:aa:aa:aa:03', None, '0x3'),
+        ]
+        self.assertEqual(sorted(expected), sorted(ret))
+
+    def test_gi_excludes_bridges(self):
+        self._mock_setup()
+        # add a device 'b1', make all return they have their "own mac",
+        # set everything other than 'b1' to be a bridge.
+        # then expect b1 is the only thing left.
+        self.data['macs']['b1'] = 'aa:aa:aa:aa:aa:b1'
+        self.data['drivers']['b1'] = None
+        self.data['devices'].add('b1')
+        self.data['bonds'] = []
+        self.data['own_macs'] = self.data['devices']
+        self.data['bridges'] = [f for f in self.data['devices'] if f != "b1"]
+        ret = net.get_interfaces()
+        self.assertEqual([('b1', 'aa:aa:aa:aa:aa:b1', None, '0x0')], ret)
+        self.mocks['is_bridge'].assert_has_calls(
+            [mock.call('bridge1'), mock.call('enp0s1'), mock.call('bond1'),
+             mock.call('b1')],
+            any_order=True)
+
+
 class TestGetInterfacesByMac(CiTestCase):
     _data = {'bonds': ['bond1'],
              'bridges': ['bridge1'],
@@ -1691,4 +1941,202 @@ def _gzip_data(data):
         gzfp.close()
         return iobuf.getvalue()
 
+
+class TestRenameInterfaces(CiTestCase):
+
+    @mock.patch('cloudinit.util.subp')
+    def test_rename_all(self, mock_subp):
+        renames = [
+            ('00:11:22:33:44:55', 'interface0', 'virtio_net', '0x3'),
+            ('00:11:22:33:44:aa', 'interface2', 'virtio_net', '0x5'),
+        ]
+        current_info = {
+            'ens3': {
+                'downable': True,
+                'device_id': '0x3',
+                'driver': 'virtio_net',
+                'mac': '00:11:22:33:44:55',
+                'name': 'ens3',
+                'up': False},
+            'ens5': {
+                'downable': True,
+                'device_id': '0x5',
+                'driver': 'virtio_net',
+                'mac': '00:11:22:33:44:aa',
+                'name': 'ens5',
+                'up': False},
+        }
+        net._rename_interfaces(renames, current_info=current_info)
+        print(mock_subp.call_args_list)
+        mock_subp.assert_has_calls([
+            mock.call(['ip', 'link', 'set', 'ens3', 'name', 'interface0'],
+                      capture=True),
+            mock.call(['ip', 'link', 'set', 'ens5', 'name', 'interface2'],
+                      capture=True),
+        ])
+
+    @mock.patch('cloudinit.util.subp')
+    def test_rename_no_driver_no_device_id(self, mock_subp):
+        renames = [
+            ('00:11:22:33:44:55', 'interface0', None, None),
+            ('00:11:22:33:44:aa', 'interface1', None, None),
+        ]
+        current_info = {
+            'eth0': {
+                'downable': True,
+                'device_id': None,
+                'driver': None,
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth0',
+                'up': False},
+            'eth1': {
+                'downable': True,
+                'device_id': None,
+                'driver': None,
+                'mac': '00:11:22:33:44:aa',
+                'name': 'eth1',
+                'up': False},
+        }
+        net._rename_interfaces(renames, current_info=current_info)
+        print(mock_subp.call_args_list)
+        mock_subp.assert_has_calls([
+            mock.call(['ip', 'link', 'set', 'eth0', 'name', 'interface0'],
+                      capture=True),
+            mock.call(['ip', 'link', 'set', 'eth1', 'name', 'interface1'],
+                      capture=True),
+        ])
+
+    @mock.patch('cloudinit.util.subp')
+    def test_rename_all_bounce(self, mock_subp):
+        renames = [
+            ('00:11:22:33:44:55', 'interface0', 'virtio_net', '0x3'),
+            ('00:11:22:33:44:aa', 'interface2', 'virtio_net', '0x5'),
+        ]
+        current_info = {
+            'ens3': {
+                'downable': True,
+                'device_id': '0x3',
+                'driver': 'virtio_net',
+                'mac': '00:11:22:33:44:55',
+                'name': 'ens3',
+                'up': True},
+            'ens5': {
+                'downable': True,
+                'device_id': '0x5',
+                'driver': 'virtio_net',
+                'mac': '00:11:22:33:44:aa',
+                'name': 'ens5',
+                'up': True},
+        }
+        net._rename_interfaces(renames, current_info=current_info)
+        print(mock_subp.call_args_list)
+        mock_subp.assert_has_calls([
+            mock.call(['ip', 'link', 'set', 'ens3', 'down'], capture=True),
+            mock.call(['ip', 'link', 'set', 'ens3', 'name', 'interface0'],
+                      capture=True),
+            mock.call(['ip', 'link', 'set', 'ens5', 'down'], capture=True),
+            mock.call(['ip', 'link', 'set', 'ens5', 'name', 'interface2'],
+                      capture=True),
+            mock.call(['ip', 'link', 'set', 'interface0', 'up'], capture=True),
+            mock.call(['ip', 'link', 'set', 'interface2', 'up'], capture=True)
+        ])
+
+    @mock.patch('cloudinit.util.subp')
+    def test_rename_duplicate_macs(self, mock_subp):
+        renames = [
+            ('00:11:22:33:44:55', 'eth0', 'hv_netsvc', '0x3'),
+            ('00:11:22:33:44:55', 'vf1', 'mlx4_core', '0x5'),
+        ]
+        current_info = {
+            'eth0': {
+                'downable': True,
+                'device_id': '0x3',
+                'driver': 'hv_netsvc',
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth0',
+                'up': False},
+            'eth1': {
+                'downable': True,
+                'device_id': '0x5',
+                'driver': 'mlx4_core',
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth1',
+                'up': False},
+        }
+        net._rename_interfaces(renames, current_info=current_info)
+        print(mock_subp.call_args_list)
+        mock_subp.assert_has_calls([
+            mock.call(['ip', 'link', 'set', 'eth1', 'name', 'vf1'],
+                      capture=True),
+        ])
+
+    @mock.patch('cloudinit.util.subp')
+    def test_rename_duplicate_macs_driver_no_devid(self, mock_subp):
+        renames = [
+            ('00:11:22:33:44:55', 'eth0', 'hv_netsvc', None),
+            ('00:11:22:33:44:55', 'vf1', 'mlx4_core', None),
+        ]
+        current_info = {
+            'eth0': {
+                'downable': True,
+                'device_id': '0x3',
+                'driver': 'hv_netsvc',
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth0',
+                'up': False},
+            'eth1': {
+                'downable': True,
+                'device_id': '0x5',
+                'driver': 'mlx4_core',
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth1',
+                'up': False},
+        }
+        net._rename_interfaces(renames, current_info=current_info)
+        print(mock_subp.call_args_list)
+        mock_subp.assert_has_calls([
+            mock.call(['ip', 'link', 'set', 'eth1', 'name', 'vf1'],
+                      capture=True),
+        ])
+
+    @mock.patch('cloudinit.util.subp')
+    def test_rename_multi_mac_dups(self, mock_subp):
+        renames = [
+            ('00:11:22:33:44:55', 'eth0', 'hv_netsvc', '0x3'),
+            ('00:11:22:33:44:55', 'vf1', 'mlx4_core', '0x5'),
+            ('00:11:22:33:44:55', 'vf2', 'mlx4_core', '0x7'),
+        ]
+        current_info = {
+            'eth0': {
+                'downable': True,
+                'device_id': '0x3',
+                'driver': 'hv_netsvc',
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth0',
+                'up': False},
+            'eth1': {
+                'downable': True,
+                'device_id': '0x5',
+                'driver': 'mlx4_core',
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth1',
+                'up': False},
+            'eth2': {
+                'downable': True,
+                'device_id': '0x7',
+                'driver': 'mlx4_core',
+                'mac': '00:11:22:33:44:55',
+                'name': 'eth2',
+                'up': False},
+        }
+        net._rename_interfaces(renames, current_info=current_info)
+        print(mock_subp.call_args_list)
+        mock_subp.assert_has_calls([
+            mock.call(['ip', 'link', 'set', 'eth1', 'name', 'vf1'],
+                      capture=True),
+            mock.call(['ip', 'link', 'set', 'eth2', 'name', 'vf2'],
+                      capture=True),
+        ])
+
+
 # vi: ts=4 expandtab

Follow ups