← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~chad.smith/cloud-init:unittests-in-cloudinit-package into cloud-init:master

 

Chad Smith has proposed merging ~chad.smith/cloud-init:unittests-in-cloudinit-package into cloud-init:master.

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

For more details, see:
https://code.launchpad.net/~chad.smith/cloud-init/+git/cloud-init/+merge/327827

cloudinit.net: add initialize_network_device function and unittest improvements.

This is not yet called, but will be called in a subsequent Ec2-related branch to manually initialize a network interface with the responses using dhcp discovery without any dhcp-script side-effects. The functionality has been tested on Ec2 ubuntu and CentOS vms to ensure that network interface initialization works in both OS-types.

Since there was poor unit test coverage for the cloudinit.net.__init__ module, this branch adds a bunch of coverage to the functions in cloudinit.net.__init. We can also now have unit tests local to the cloudinit modules. The benefits of having unittests under cloudinit module:
 - Proximity of unittest to cloudinit module makes it easier for ongoing devs to know where to augment unit tests. The tests.unittest directory is organizated such that it 
 - Allows for 1 to 1 name mapping module -> tests/test_module.py
 - Improved test and module isolation, if we find unit tests have to import from a number of modules besides the module under test, it will better prompt resturcturing of the module.



This also branch touches:
 - tox.ini to run unit tests found in cloudinit as well as include mock test-requirement for pylint since we now have unit tests living within cloudinit package
 - setup.py to exclude any test modules under cloudinit when packaging



To test:
   make deb
   dpkg -c cloud-init_all.deb | grep test   # make sure our package didn't include tests
   tox
   # on an lxc: ifconfig eth0 0.0.0.0;  python -c 'from cloudinit.net import initialize_network_device; initialize_network_device("eth0", "ip-addr", "netmask", "broadcast")'
   
-- 
Your team cloud-init commiters is requested to review the proposed merge of ~chad.smith/cloud-init:unittests-in-cloudinit-package into cloud-init:master.
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index d1740e5..5a4a232 100644
--- a/cloudinit/net/__init__.py
+++ b/cloudinit/net/__init__.py
@@ -10,6 +10,7 @@ import logging
 import os
 import re
 
+from cloudinit.net.network_state import mask_to_net_prefix
 from cloudinit import util
 
 LOG = logging.getLogger(__name__)
@@ -77,7 +78,7 @@ def read_sys_net_int(iface, field):
         return None
     try:
         return int(val)
-    except TypeError:
+    except ValueError:
         return None
 
 
@@ -149,7 +150,14 @@ def device_devid(devname):
 
 
 def get_devicelist():
-    return os.listdir(SYS_CLASS_NET)
+    try:
+        devs = os.listdir(SYS_CLASS_NET)
+    except OSError as e:
+        if e.errno == errno.ENOENT:
+            devs = []
+        else:
+            raise
+    return devs
 
 
 class ParserError(Exception):
@@ -497,14 +505,8 @@ def get_interfaces_by_mac():
     """Build a dictionary of tuples {mac: name}.
 
     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 = {}
+    devs = get_devicelist()
     empty_mac = '00:00:00:00:00:00'
     for name in devs:
         if not interface_has_own_mac(name):
@@ -531,14 +533,8 @@ 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 = []
+    devs = get_devicelist()
     empty_mac = '00:00:00:00:00:00'
     for name in devs:
         if not interface_has_own_mac(name):
@@ -557,6 +553,27 @@ def get_interfaces():
     return ret
 
 
+def initialize_network_device(interface, ip_address, netmask, broadcast,
+                              router=None):
+    if not all([interface, ip_address, netmask, broadcast]):
+        raise ValueError(
+            "Cannot init network dev {0} with ip{1}/{2} and bcast {3}".format(
+                interface, ip_address, netmask, broadcast))
+    prefix = mask_to_net_prefix(netmask)
+
+    util.subp([
+        'ip', '-family', 'inet', 'addr', 'add', '%s/%s' % (ip_address, prefix),
+        'broadcast', broadcast, 'dev', interface], capture=True)
+    util.subp(
+        ['ip', '-family', 'inet', 'link', 'set', 'dev', interface, 'up'],
+        capture=True)
+    if router:
+        util.subp(
+            ['ip', '-4', 'route', 'add', 'default', 'via', router, 'dev',
+             interface],
+            capture=True)
+
+
 class RendererNotFoundError(RuntimeError):
     pass
 
diff --git a/cloudinit/net/tests/__init__.py b/cloudinit/net/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/cloudinit/net/tests/__init__.py
diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
new file mode 100644
index 0000000..8dc06b3
--- /dev/null
+++ b/cloudinit/net/tests/test_init.py
@@ -0,0 +1,406 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import copy
+import mock
+import os
+
+import cloudinit.net as net
+from cloudinit.util import ensure_file, write_file
+from tests.unittests.helpers import CiTestCase
+
+
+class TestSysDevPath(CiTestCase):
+
+    def test_sys_dev_path(self):
+        """sys_dev_path returns a path under SYS_CLASS_NET for a device."""
+        dev = 'something'
+        path = 'attribute'
+        expected = net.SYS_CLASS_NET + dev + '/' + path
+        self.assertEqual(expected, net.sys_dev_path(dev, path))
+
+    def test_sys_dev_path_without_path(self):
+        """When path param isn't provided it defaults to empty string."""
+        dev = 'something'
+        expected = net.SYS_CLASS_NET + dev + '/'
+        self.assertEqual(expected, net.sys_dev_path(dev))
+
+
+class TestReadSysNet(CiTestCase):
+    with_logs = True
+
+    def setUp(self):
+        super(TestReadSysNet, self).setUp()
+        self.sysdir = self.tmp_dir()
+        self.net = net
+        self.net.SYS_CLASS_NET = self.sysdir + '/'
+
+    def test_read_sys_net_strips_contents_of_sys_path(self):
+        """read_sys_net strips whitespace from the contents of a sys file."""
+        content = 'some stuff with trailing whitespace\t\r\n'
+        write_file(os.path.join(self.sysdir, 'dev', 'attr'), content)
+        self.assertEqual(content.strip(), net.read_sys_net('dev', 'attr'))
+
+    def test_read_sys_net_reraises_oserror(self):
+        """read_sys_net raises OSError/IOError when file doesn't exist."""
+        # Non-specific Exception because versions of python OSError vs IOError.
+        with self.assertRaises(Exception) as context_manager:  # noqa: H202
+            net.read_sys_net('dev', 'attr')
+        error = context_manager.exception
+        self.assertIn('No such file or directory', str(error))
+
+    def test_read_sys_net_handles_error_with_on_enoent(self):
+        """read_sys_net handles OSError/IOError with on_enoent if provided."""
+        handled_errors = []
+
+        def on_enoent(e):
+            handled_errors.append(e)
+
+        net.read_sys_net('dev', 'attr', on_enoent=on_enoent)
+        error = handled_errors[0]
+        self.assertIsInstance(error, Exception)
+        self.assertIn('No such file or directory', str(error))
+
+    def test_read_sys_net_translates_content(self):
+        """read_sys_net translates content when translate dict is provided."""
+        content = "you're welcome\n"
+        write_file(os.path.join(self.sysdir, 'dev', 'attr'), content)
+        translate = {"you're welcome": 'de nada'}
+        self.assertEqual(
+            'de nada',
+            net.read_sys_net('dev', 'attr', translate=translate))
+
+    def test_read_sys_net_errors_on_translation_failures(self):
+        """read_sys_net raises and KeyError and logs details on failure."""
+        content = "you're welcome\n"
+        write_file(os.path.join(self.sysdir, 'dev', 'attr'), content)
+        with self.assertRaises(KeyError) as context_manager:
+            net.read_sys_net('dev', 'attr', translate={})
+        error = context_manager.exception
+        self.assertEqual('"you\'re welcome"', str(error))
+        self.assertIn(
+            "Found unexpected (not translatable) value 'you're welcome' in "
+            "'{0}/dev/attr".format(self.sysdir),
+            self.logs.getvalue())
+
+    def test_read_sys_net_handles_translation_errors_with_onkeyerror(self):
+        """read_sys_net handles translation errors calling on_keyerror."""
+        content = "you're welcome\n"
+        write_file(os.path.join(self.sysdir, 'dev', 'attr'), content)
+        handled_errors = []
+
+        def on_keyerror(e):
+            handled_errors.append(e)
+
+        net.read_sys_net('dev', 'attr', translate={}, on_keyerror=on_keyerror)
+        error = handled_errors[0]
+        self.assertIsInstance(error, KeyError)
+        self.assertEqual('"you\'re welcome"', str(error))
+
+    def test_read_sys_net_safe_returns_false_on_translate_failure(self):
+        """read_sys_net_safe returns False on translation failures."""
+        content = "you're welcome\n"
+        write_file(os.path.join(self.sysdir, 'dev', 'attr'), content)
+        self.assertFalse(net.read_sys_net_safe('dev', 'attr', translate={}))
+
+    def test_read_sys_net_safe_returns_false_on_noent_failure(self):
+        """read_sys_net_safe returns False on file not found failures."""
+        self.assertFalse(net.read_sys_net_safe('dev', 'attr'))
+
+    def test_read_sys_net_int_returns_none_on_error(self):
+        """read_sys_net_safe returns None on failures."""
+        self.assertFalse(net.read_sys_net_int('dev', 'attr'))
+
+    def test_read_sys_net_int_returns_none_on_valueerror(self):
+        """read_sys_net_safe returns None when content is not an int."""
+        write_file(os.path.join(self.sysdir, 'dev', 'attr'), 'NOTINT\n')
+        self.assertFalse(net.read_sys_net_int('dev', 'attr'))
+
+    def test_read_sys_net_int_returns_integer_from_content(self):
+        """read_sys_net_safe returns None on failures."""
+        write_file(os.path.join(self.sysdir, 'dev', 'attr'), '1\n')
+        self.assertEqual(1, net.read_sys_net_int('dev', 'attr'))
+
+    def test_is_up_true(self):
+        """is_up is True if sys/net/devname/operstate is 'up' or 'unknown'."""
+        for state in ['up', 'unknown']:
+            write_file(os.path.join(self.sysdir, 'eth0', 'operstate'), state)
+            self.assertTrue(net.is_up('eth0'))
+
+    def test_is_up_false(self):
+        """is_up is False if sys/net/devname/operstate is 'down' or invalid."""
+        for state in ['down', 'incomprehensible']:
+            write_file(os.path.join(self.sysdir, 'eth0', 'operstate'), state)
+            self.assertFalse(net.is_up('eth0'))
+
+    def test_is_wireless(self):
+        """is_wireless is True when /sys/net/devname/wireless exists."""
+        self.assertFalse(net.is_wireless('eth0'))
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'wireless'))
+        self.assertTrue(net.is_wireless('eth0'))
+
+    def test_is_bridge(self):
+        """is_bridge is True when /sys/net/devname/bridge exists."""
+        self.assertFalse(net.is_bridge('eth0'))
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'bridge'))
+        self.assertTrue(net.is_bridge('eth0'))
+
+    def test_is_bond(self):
+        """is_bond is True when /sys/net/devname/bonding exists."""
+        self.assertFalse(net.is_bond('eth0'))
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'bonding'))
+        self.assertTrue(net.is_bond('eth0'))
+
+    def test_is_vlan(self):
+        """is_vlan is True when /sys/net/devname/uevent has DEVTYPE=vlan."""
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'uevent'))
+        self.assertFalse(net.is_vlan('eth0'))
+        content = 'junk\nDEVTYPE=vlan\njunk\n'
+        write_file(os.path.join(self.sysdir, 'eth0', 'uevent'), content)
+        self.assertTrue(net.is_vlan('eth0'))
+
+    def test_is_connected_when_physically_connected(self):
+        """is_connected is True when /sys/net/devname/iflink reports 2."""
+        self.assertFalse(net.is_connected('eth0'))
+        write_file(os.path.join(self.sysdir, 'eth0', 'iflink'), "2")
+        self.assertTrue(net.is_connected('eth0'))
+
+    def test_is_connected_when_wireless_and_carrier_active(self):
+        """is_connected is True if wireless /sys/net/devname/carrier is 1."""
+        self.assertFalse(net.is_connected('eth0'))
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'wireless'))
+        self.assertFalse(net.is_connected('eth0'))
+        write_file(os.path.join(self.sysdir, 'eth0', 'carrier'), "1")
+        self.assertTrue(net.is_connected('eth0'))
+
+    def test_is_physical(self):
+        """is_physical is True when /sys/net/devname/device exists."""
+        self.assertFalse(net.is_physical('eth0'))
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'device'))
+        self.assertTrue(net.is_physical('eth0'))
+
+    def test_is_present(self):
+        """is_present is True when /sys/net/devname exists."""
+        self.assertFalse(net.is_present('eth0'))
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'device'))
+        self.assertTrue(net.is_present('eth0'))
+
+
+class TestGenerateFallbackConfig(CiTestCase):
+
+    def setUp(self):
+        super(TestGenerateFallbackConfig, self).setUp()
+        self.sysdir = self.tmp_dir()
+        self.net = net
+        self.net.SYS_CLASS_NET = self.sysdir + '/'
+
+    def test_generate_fallback_finds_connected_eth_with_mac(self):
+        """generate_fallback_config finds any connected device with a mac."""
+        write_file(os.path.join(self.sysdir, 'eth0', 'carrier'), '1')
+        write_file(os.path.join(self.sysdir, 'eth1', 'carrier'), '1')
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth1', 'address'), mac)
+        expected = {
+            'config': [{'type': 'physical', 'mac_address': mac,
+                        'name': 'eth1', 'subnets': [{'type': 'dhcp'}]}],
+            'version': 1}
+        self.assertEqual(expected, net.generate_fallback_config())
+
+    def test_generate_fallback_finds_dormant_eth_with_mac(self):
+        """generate_fallback_config finds any dormant device with a mac."""
+        write_file(os.path.join(self.sysdir, 'eth0', 'dormant'), '1')
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth0', 'address'), mac)
+        expected = {
+            'config': [{'type': 'physical', 'mac_address': mac,
+                        'name': 'eth0', 'subnets': [{'type': 'dhcp'}]}],
+            'version': 1}
+        self.assertEqual(expected, net.generate_fallback_config())
+
+    def test_generate_fallback_finds_eth_by_operstate(self):
+        """generate_fallback_config finds any dormant device with a mac."""
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth0', 'address'), mac)
+        expected = {
+            'config': [{'type': 'physical', 'mac_address': mac,
+                        'name': 'eth0', 'subnets': [{'type': 'dhcp'}]}],
+            'version': 1}
+        valid_operstates = ['dormant', 'down', 'lowerlayerdown', 'unknown']
+        for state in valid_operstates:
+            write_file(os.path.join(self.sysdir, 'eth0', 'operstate'), state)
+            self.assertEqual(expected, net.generate_fallback_config())
+        write_file(os.path.join(self.sysdir, 'eth0', 'operstate'), 'noworky')
+        self.assertIsNone(net.generate_fallback_config())
+
+    def test_generate_fallback_config_skips_veth(self):
+        """generate_fallback_config will skip any veth interfaces."""
+        # A connected veth which gets ignored
+        write_file(os.path.join(self.sysdir, 'veth0', 'carrier'), '1')
+        self.assertIsNone(net.generate_fallback_config())
+
+    def test_generate_fallback_config_skips_bridges(self):
+        """generate_fallback_config will skip any bridges interfaces."""
+        # A connected veth which gets ignored
+        write_file(os.path.join(self.sysdir, 'eth0', 'carrier'), '1')
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth0', 'address'), mac)
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'bridge'))
+        self.assertIsNone(net.generate_fallback_config())
+
+    def test_generate_fallback_config_skips_bonds(self):
+        """generate_fallback_config will skip any bonded interfaces."""
+        # A connected veth which gets ignored
+        write_file(os.path.join(self.sysdir, 'eth0', 'carrier'), '1')
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth0', 'address'), mac)
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'bonding'))
+        self.assertIsNone(net.generate_fallback_config())
+
+
+class TestGetDeviceList(CiTestCase):
+
+    def setUp(self):
+        super(TestGetDeviceList, self).setUp()
+        self.sysdir = self.tmp_dir()
+        self.net = net
+        self.net.SYS_CLASS_NET = self.sysdir + '/'
+
+    def test_get_device_list_empty_without_sys_net(self):
+        """get_device_list returns empty list when missing SYS_CLASS_NET."""
+        self.net.SYS_CLASS_NET = 'idontexist'
+        self.assertEqual([], net.get_interfaces())
+
+    def test_get_interfaces_empty_list_without_sys_net(self):
+        """get_interfaces returns an empty list when missing SYS_CLASS_NET."""
+        self.net.SYS_CLASS_NET = 'idontexist'
+        self.assertEqual([], net.get_interfaces())
+
+
+class TestGetInterfaceMAC(CiTestCase):
+
+    def setUp(self):
+        super(TestGetInterfaceMAC, self).setUp()
+        self.sysdir = self.tmp_dir()
+        self.net = net
+        self.net.SYS_CLASS_NET = self.sysdir + '/'
+
+    def test_get_interface_mac_false_with_no_mac(self):
+        """get_device_list returns False when no mac is reported."""
+        ensure_file(os.path.join(self.sysdir, 'eth0', 'bonding'))
+        mac_path = os.path.join(self.net.SYS_CLASS_NET, 'eth0', 'address')
+        self.assertFalse(os.path.exists(mac_path))
+        self.assertFalse(net.get_interface_mac('eth0'))
+
+    def test_get_interface_mac(self):
+        """get_interfaces returns the mac from SYS_CLASS_NET/dev/address."""
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth1', 'address'), mac)
+        self.assertEqual(mac, net.get_interface_mac('eth1'))
+
+    def test_get_interface_mac_grabs_bonding_address(self):
+        """get_interfaces returns the source device mac for bonded devices."""
+        source_dev_mac = 'aa:bb:cc:aa:bb:cc'
+        bonded_mac = 'dd:ee:ff:dd:ee:ff'
+        write_file(os.path.join(self.sysdir, 'eth1', 'address'), bonded_mac)
+        write_file(
+            os.path.join(self.sysdir, 'eth1', 'bonding_slave', 'perm_hwaddr'),
+            source_dev_mac)
+        self.assertEqual(source_dev_mac, net.get_interface_mac('eth1'))
+
+    def test_get_interfaces_by_mac_skips_empty_mac(self):
+        """Ignore 00:00:00:00:00:00 addresses from get_interfaces_by_mac."""
+        empty_mac = '00:00:00:00:00:00'
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth1', 'address'), empty_mac)
+        write_file(os.path.join(self.sysdir, 'eth1', 'addr_assign_type'), '0')
+        write_file(os.path.join(self.sysdir, 'eth2', 'addr_assign_type'), '0')
+        write_file(os.path.join(self.sysdir, 'eth2', 'address'), mac)
+        expected = [('eth2', 'aa:bb:cc:aa:bb:cc', None, None)]
+        self.assertEqual(expected, net.get_interfaces())
+
+    def test_get_interfaces_by_mac_skips_missing_mac(self):
+        """Ignore interfaces without an address from get_interfaces_by_mac."""
+        write_file(os.path.join(self.sysdir, 'eth1', 'addr_assign_type'), '0')
+        address_path = os.path.join(self.sysdir, 'eth1', 'address')
+        self.assertFalse(os.path.exists(address_path))
+        mac = 'aa:bb:cc:aa:bb:cc'
+        write_file(os.path.join(self.sysdir, 'eth2', 'addr_assign_type'), '0')
+        write_file(os.path.join(self.sysdir, 'eth2', 'address'), mac)
+        expected = [('eth2', 'aa:bb:cc:aa:bb:cc', None, None)]
+        self.assertEqual(expected, net.get_interfaces())
+
+
+class TestInterfaceHasOwnMAC(CiTestCase):
+
+    def setUp(self):
+        super(TestInterfaceHasOwnMAC, self).setUp()
+        self.sysdir = self.tmp_dir()
+        self.net = net
+        self.net.SYS_CLASS_NET = self.sysdir + '/'
+
+    def test_interface_has_own_mac_false_when_stolen(self):
+        """Return False from interface_has_own_mac when address is stolen."""
+        write_file(os.path.join(self.sysdir, 'eth1', 'addr_assign_type'), '2')
+        self.assertFalse(net.interface_has_own_mac('eth1'))
+
+    def test_interface_has_own_mac_true_when_not_stolen(self):
+        """Return False from interface_has_own_mac when mac isn't stolen."""
+        valid_assign_types = ['0', '1', '3']
+        assign_path = os.path.join(self.sysdir, 'eth1', 'addr_assign_type')
+        for _type in valid_assign_types:
+            write_file(assign_path, _type)
+            self.assertTrue(net.interface_has_own_mac('eth1'))
+
+    def test_interface_has_own_mac_strict_errors_on_absent_assign_type(self):
+        """When addr_assign_type is absent, interface_has_own_mac errors."""
+        with self.assertRaises(ValueError):
+            net.interface_has_own_mac('eth1', strict=True)
+
+
+@mock.patch('cloudinit.net.util.subp')
+class TestInitializeNetworkDevice(CiTestCase):
+
+    def test_initialize_network_devices_errors_on_missing_params(self, m_subp):
+        """All parameters for initialize_network_device cannot be None."""
+        required_params = {
+            'interface': 'eth0', 'ip_address': '192.168.2.2',
+            'netmask': '255.255.255.0', 'broadcast': '192.168.2.255'}
+        for key in required_params.keys():
+            params = copy.deepcopy(required_params)
+            params[key] = None
+            with self.assertRaises(ValueError) as context_manager:
+                net.initialize_network_device(**params)
+            error = context_manager.exception
+            self.assertIn('Cannot init network dev', str(error))
+            self.assertEqual(0, m_subp.call_count)
+
+    def test_initialize_network_device_without_router(self, m_subp):
+        """When router is None, initialize_network_device adds no gateway."""
+        params = {
+            'interface': 'eth0', 'ip_address': '192.168.2.2',
+            'netmask': '255.255.255.0', 'broadcast': '192.168.2.255'}
+        net.initialize_network_device(**params)
+        m_subp.assert_has_calls([
+            mock.call([
+                'ip', '-family', 'inet', 'addr', 'add', '192.168.2.2/24',
+                'broadcast', '192.168.2.255', 'dev', 'eth0'], capture=True),
+            mock.call(
+                ['ip', '-family', 'inet', 'link', 'set', 'dev', 'eth0', 'up'],
+                capture=True)])
+
+    def test_initialize_network_device_with_router(self, m_subp):
+        """When router is None, initialize_network_device adds a gateway."""
+        params = {
+            'interface': 'eth0', 'ip_address': '192.168.2.2',
+            'netmask': '255.255.255.0', 'broadcast': '192.168.2.255',
+            'router': '192.168.2.1'}
+        net.initialize_network_device(**params)
+        m_subp.assert_has_calls([
+            mock.call([
+                'ip', '-family', 'inet', 'addr', 'add', '192.168.2.2/24',
+                'broadcast', '192.168.2.255', 'dev', 'eth0'], capture=True),
+            mock.call(
+                ['ip', '-family', 'inet', 'link', 'set', 'dev', 'eth0', 'up'],
+                capture=True),
+            mock.call(
+                ['ip', '-4', 'route', 'add', 'default', 'via',
+                 params['router'], 'dev', 'eth0'], capture=True)])
diff --git a/setup.py b/setup.py
index 1197ece..ebd451d 100755
--- a/setup.py
+++ b/setup.py
@@ -236,7 +236,7 @@ setuptools.setup(
     author='Scott Moser',
     author_email='scott.moser@xxxxxxxxxxxxx',
     url='http://launchpad.net/cloud-init/',
-    packages=setuptools.find_packages(exclude=['tests']),
+    packages=setuptools.find_packages(exclude=['tests.*', '*.tests', 'tests']),
     scripts=['tools/cloud-init-per'],
     license='Dual-licensed under GPLv3 or Apache 2.0',
     data_files=data_files,
diff --git a/tox.ini b/tox.ini
index 1140f9b..0bf9ee8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -21,7 +21,11 @@ setenv =
     LC_ALL = en_US.utf-8
 
 [testenv:pylint]
-deps = pylint==1.7.1
+deps = 
+    # requirements
+    pylint==1.7.1
+    # test-requirements
+    mock==1.3.0
 commands = {envpython} -m pylint {posargs:cloudinit}
 
 [testenv:py3]
@@ -29,7 +33,7 @@ basepython = python3
 deps = -r{toxinidir}/test-requirements.txt
 commands = {envpython} -m nose {posargs:--with-coverage \
            --cover-erase --cover-branches --cover-inclusive \
-           --cover-package=cloudinit tests/unittests}
+           --cover-package=cloudinit tests/unittests cloudinit}
 
 [testenv:py27]
 basepython = python2.7
@@ -98,7 +102,11 @@ deps = pyflakes
 
 [testenv:tip-pylint]
 commands = {envpython} -m pylint {posargs:cloudinit}
-deps = pylint
+deps =
+    # requirements
+    pylint
+    # test-requirements
+    mock==1.3.0
 
 [testenv:citest]
 basepython = python3

Follow ups