summaryrefslogtreecommitdiff
path: root/cloudinit/net
diff options
context:
space:
mode:
Diffstat (limited to 'cloudinit/net')
-rw-r--r--cloudinit/net/__init__.py35
-rw-r--r--cloudinit/net/dhcp.py119
-rw-r--r--cloudinit/net/tests/test_dhcp.py144
-rw-r--r--cloudinit/net/tests/test_init.py2
4 files changed, 284 insertions, 16 deletions
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index 1ff8fae0..a1b0db10 100644
--- a/cloudinit/net/__init__.py
+++ b/cloudinit/net/__init__.py
@@ -175,13 +175,8 @@ def is_disabled_cfg(cfg):
return cfg.get('config') == "disabled"
-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
-
+def find_fallback_nic(blacklist_drivers=None):
+ """Return the name of the 'fallback' network device."""
if not blacklist_drivers:
blacklist_drivers = []
@@ -233,15 +228,24 @@ def generate_fallback_config(blacklist_drivers=None, config_driver=None):
if DEFAULT_PRIMARY_INTERFACE in names:
names.remove(DEFAULT_PRIMARY_INTERFACE)
names.insert(0, DEFAULT_PRIMARY_INTERFACE)
- target_name = None
- target_mac = None
+
+ # pick the first that has a mac-address
for name in names:
- mac = read_sys_net_safe(name, 'address')
- if mac:
- target_name = name
- target_mac = mac
- break
- if target_mac and target_name:
+ if read_sys_net_safe(name, 'address'):
+ return name
+ return None
+
+
+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
+
+ target_name = find_fallback_nic(blacklist_drivers=blacklist_drivers)
+ if target_name:
+ target_mac = read_sys_net_safe(target_name, 'address')
nconf = {'config': [], 'version': 1}
cfg = {'type': 'physical', 'name': target_name,
'mac_address': target_mac, 'subnets': [{'type': 'dhcp'}]}
@@ -585,6 +589,7 @@ class EphemeralIPv4Network(object):
self._bringup_router()
def __exit__(self, excp_type, excp_value, excp_traceback):
+ """Teardown anything we set up."""
for cmd in self.cleanup_cmds:
util.subp(cmd, capture=True)
diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py
new file mode 100644
index 00000000..c7febc57
--- /dev/null
+++ b/cloudinit/net/dhcp.py
@@ -0,0 +1,119 @@
+# Copyright (C) 2017 Canonical Ltd.
+#
+# Author: Chad Smith <chad.smith@canonical.com>
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import logging
+import os
+import re
+
+from cloudinit.net import find_fallback_nic, get_devicelist
+from cloudinit import util
+
+LOG = logging.getLogger(__name__)
+
+
+class InvalidDHCPLeaseFileError(Exception):
+ """Raised when parsing an empty or invalid dhcp.leases file.
+
+ Current uses are DataSourceAzure and DataSourceEc2 during ephemeral
+ boot to scrape metadata.
+ """
+ pass
+
+
+def maybe_perform_dhcp_discovery(nic=None):
+ """Perform dhcp discovery if nic valid and dhclient command exists.
+
+ If the nic is invalid or undiscoverable or dhclient command is not found,
+ skip dhcp_discovery and return an empty dict.
+
+ @param nic: Name of the network interface we want to run dhclient on.
+ @return: A dict of dhcp options from the dhclient discovery if run,
+ otherwise an empty dict is returned.
+ """
+ if nic is None:
+ nic = find_fallback_nic()
+ if nic is None:
+ LOG.debug(
+ 'Skip dhcp_discovery: Unable to find fallback nic.')
+ return {}
+ elif nic not in get_devicelist():
+ LOG.debug(
+ 'Skip dhcp_discovery: nic %s not found in get_devicelist.', nic)
+ return {}
+ dhclient_path = util.which('dhclient')
+ if not dhclient_path:
+ LOG.debug('Skip dhclient configuration: No dhclient command found.')
+ return {}
+ with util.tempdir(prefix='cloud-init-dhcp-') as tmpdir:
+ return dhcp_discovery(dhclient_path, nic, tmpdir)
+
+
+def parse_dhcp_lease_file(lease_file):
+ """Parse the given dhcp lease file for the most recent lease.
+
+ Return a dict of dhcp options as key value pairs for the most recent lease
+ block.
+
+ @raises: InvalidDHCPLeaseFileError on empty of unparseable leasefile
+ content.
+ """
+ lease_regex = re.compile(r"lease {(?P<lease>[^}]*)}\n")
+ dhcp_leases = []
+ lease_content = util.load_file(lease_file)
+ if len(lease_content) == 0:
+ raise InvalidDHCPLeaseFileError(
+ 'Cannot parse empty dhcp lease file {0}'.format(lease_file))
+ for lease in lease_regex.findall(lease_content):
+ lease_options = []
+ for line in lease.split(';'):
+ # Strip newlines, double-quotes and option prefix
+ line = line.strip().replace('"', '').replace('option ', '')
+ if not line:
+ continue
+ lease_options.append(line.split(' ', 1))
+ dhcp_leases.append(dict(lease_options))
+ if not dhcp_leases:
+ raise InvalidDHCPLeaseFileError(
+ 'Cannot parse dhcp lease file {0}. No leases found'.format(
+ lease_file))
+ return dhcp_leases
+
+
+def dhcp_discovery(dhclient_cmd_path, interface, cleandir):
+ """Run dhclient on the interface without scripts or filesystem artifacts.
+
+ @param dhclient_cmd_path: Full path to the dhclient used.
+ @param interface: Name of the network inteface on which to dhclient.
+ @param cleandir: The directory from which to run dhclient as well as store
+ dhcp leases.
+
+ @return: A dict of dhcp options parsed from the dhcp.leases file or empty
+ dict.
+ """
+ LOG.debug('Performing a dhcp discovery on %s', interface)
+
+ # XXX We copy dhclient out of /sbin/dhclient to avoid dealing with strict
+ # app armor profiles which disallow running dhclient -sf <our-script-file>.
+ # We want to avoid running /sbin/dhclient-script because of side-effects in
+ # /etc/resolv.conf any any other vendor specific scripts in
+ # /etc/dhcp/dhclient*hooks.d.
+ sandbox_dhclient_cmd = os.path.join(cleandir, 'dhclient')
+ util.copy(dhclient_cmd_path, sandbox_dhclient_cmd)
+ pid_file = os.path.join(cleandir, 'dhclient.pid')
+ lease_file = os.path.join(cleandir, 'dhcp.leases')
+
+ # ISC dhclient needs the interface up to send initial discovery packets.
+ # Generally dhclient relies on dhclient-script PREINIT action to bring the
+ # link up before attempting discovery. Since we are using -sf /bin/true,
+ # we need to do that "link up" ourselves first.
+ util.subp(['ip', 'link', 'set', 'dev', interface, 'up'], capture=True)
+ cmd = [sandbox_dhclient_cmd, '-1', '-v', '-lf', lease_file,
+ '-pf', pid_file, interface, '-sf', '/bin/true']
+ util.subp(cmd, capture=True)
+ return parse_dhcp_lease_file(lease_file)
+
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/net/tests/test_dhcp.py b/cloudinit/net/tests/test_dhcp.py
new file mode 100644
index 00000000..47d8d461
--- /dev/null
+++ b/cloudinit/net/tests/test_dhcp.py
@@ -0,0 +1,144 @@
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import mock
+import os
+from textwrap import dedent
+
+from cloudinit.net.dhcp import (
+ InvalidDHCPLeaseFileError, maybe_perform_dhcp_discovery,
+ parse_dhcp_lease_file, dhcp_discovery)
+from cloudinit.util import ensure_file, write_file
+from tests.unittests.helpers import CiTestCase
+
+
+class TestParseDHCPLeasesFile(CiTestCase):
+
+ def test_parse_empty_lease_file_errors(self):
+ """parse_dhcp_lease_file errors when file content is empty."""
+ empty_file = self.tmp_path('leases')
+ ensure_file(empty_file)
+ with self.assertRaises(InvalidDHCPLeaseFileError) as context_manager:
+ parse_dhcp_lease_file(empty_file)
+ error = context_manager.exception
+ self.assertIn('Cannot parse empty dhcp lease file', str(error))
+
+ def test_parse_malformed_lease_file_content_errors(self):
+ """parse_dhcp_lease_file errors when file content isn't dhcp leases."""
+ non_lease_file = self.tmp_path('leases')
+ write_file(non_lease_file, 'hi mom.')
+ with self.assertRaises(InvalidDHCPLeaseFileError) as context_manager:
+ parse_dhcp_lease_file(non_lease_file)
+ error = context_manager.exception
+ self.assertIn('Cannot parse dhcp lease file', str(error))
+
+ def test_parse_multiple_leases(self):
+ """parse_dhcp_lease_file returns a list of all leases within."""
+ lease_file = self.tmp_path('leases')
+ content = dedent("""
+ lease {
+ interface "wlp3s0";
+ fixed-address 192.168.2.74;
+ option subnet-mask 255.255.255.0;
+ option routers 192.168.2.1;
+ renew 4 2017/07/27 18:02:30;
+ expire 5 2017/07/28 07:08:15;
+ }
+ lease {
+ interface "wlp3s0";
+ fixed-address 192.168.2.74;
+ option subnet-mask 255.255.255.0;
+ option routers 192.168.2.1;
+ }
+ """)
+ expected = [
+ {'interface': 'wlp3s0', 'fixed-address': '192.168.2.74',
+ 'subnet-mask': '255.255.255.0', 'routers': '192.168.2.1',
+ 'renew': '4 2017/07/27 18:02:30',
+ 'expire': '5 2017/07/28 07:08:15'},
+ {'interface': 'wlp3s0', 'fixed-address': '192.168.2.74',
+ 'subnet-mask': '255.255.255.0', 'routers': '192.168.2.1'}]
+ write_file(lease_file, content)
+ self.assertItemsEqual(expected, parse_dhcp_lease_file(lease_file))
+
+
+class TestDHCPDiscoveryClean(CiTestCase):
+ with_logs = True
+
+ @mock.patch('cloudinit.net.dhcp.find_fallback_nic')
+ def test_no_fallback_nic_found(self, m_fallback_nic):
+ """Log and do nothing when nic is absent and no fallback is found."""
+ m_fallback_nic.return_value = None # No fallback nic found
+ self.assertEqual({}, maybe_perform_dhcp_discovery())
+ self.assertIn(
+ 'Skip dhcp_discovery: Unable to find fallback nic.',
+ self.logs.getvalue())
+
+ def test_provided_nic_does_not_exist(self):
+ """When the provided nic doesn't exist, log a message and no-op."""
+ self.assertEqual({}, maybe_perform_dhcp_discovery('idontexist'))
+ self.assertIn(
+ 'Skip dhcp_discovery: nic idontexist not found in get_devicelist.',
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.net.dhcp.util.which')
+ @mock.patch('cloudinit.net.dhcp.find_fallback_nic')
+ def test_absent_dhclient_command(self, m_fallback, m_which):
+ """When dhclient doesn't exist in the OS, log the issue and no-op."""
+ m_fallback.return_value = 'eth9'
+ m_which.return_value = None # dhclient isn't found
+ self.assertEqual({}, maybe_perform_dhcp_discovery())
+ self.assertIn(
+ 'Skip dhclient configuration: No dhclient command found.',
+ self.logs.getvalue())
+
+ @mock.patch('cloudinit.net.dhcp.dhcp_discovery')
+ @mock.patch('cloudinit.net.dhcp.util.which')
+ @mock.patch('cloudinit.net.dhcp.find_fallback_nic')
+ def test_dhclient_run_with_tmpdir(self, m_fallback, m_which, m_dhcp):
+ """maybe_perform_dhcp_discovery passes tmpdir to dhcp_discovery."""
+ m_fallback.return_value = 'eth9'
+ m_which.return_value = '/sbin/dhclient'
+ m_dhcp.return_value = {'address': '192.168.2.2'}
+ self.assertEqual(
+ {'address': '192.168.2.2'}, maybe_perform_dhcp_discovery())
+ m_dhcp.assert_called_once()
+ call = m_dhcp.call_args_list[0]
+ self.assertEqual('/sbin/dhclient', call[0][0])
+ self.assertEqual('eth9', call[0][1])
+ self.assertIn('/tmp/cloud-init-dhcp-', call[0][2])
+
+ @mock.patch('cloudinit.net.dhcp.util.subp')
+ def test_dhcp_discovery_run_in_sandbox(self, m_subp):
+ """dhcp_discovery brings up the interface and runs dhclient.
+
+ It also returns the parsed dhcp.leases file generated in the sandbox.
+ """
+ tmpdir = self.tmp_dir()
+ dhclient_script = os.path.join(tmpdir, 'dhclient.orig')
+ script_content = '#!/bin/bash\necho fake-dhclient'
+ write_file(dhclient_script, script_content, mode=0o755)
+ lease_content = dedent("""
+ lease {
+ interface "eth9";
+ fixed-address 192.168.2.74;
+ option subnet-mask 255.255.255.0;
+ option routers 192.168.2.1;
+ }
+ """)
+ lease_file = os.path.join(tmpdir, 'dhcp.leases')
+ write_file(lease_file, lease_content)
+ self.assertItemsEqual(
+ [{'interface': 'eth9', 'fixed-address': '192.168.2.74',
+ 'subnet-mask': '255.255.255.0', 'routers': '192.168.2.1'}],
+ dhcp_discovery(dhclient_script, 'eth9', tmpdir))
+ # dhclient script got copied
+ with open(os.path.join(tmpdir, 'dhclient')) as stream:
+ self.assertEqual(script_content, stream.read())
+ # Interface was brought up before dhclient called from sandbox
+ m_subp.assert_has_calls([
+ mock.call(
+ ['ip', 'link', 'set', 'dev', 'eth9', 'up'], capture=True),
+ mock.call(
+ [os.path.join(tmpdir, 'dhclient'), '-1', '-v', '-lf',
+ lease_file, '-pf', os.path.join(tmpdir, 'dhclient.pid'),
+ 'eth9', '-sf', '/bin/true'], capture=True)])
diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
index 272a6ebd..cc052a7d 100644
--- a/cloudinit/net/tests/test_init.py
+++ b/cloudinit/net/tests/test_init.py
@@ -414,7 +414,7 @@ class TestEphemeralIPV4Network(CiTestCase):
self.assertIn('Cannot init network on', str(error))
self.assertEqual(0, m_subp.call_count)
- def test_ephemeral_ipv4_network_errors_invalid_mask(self, m_subp):
+ def test_ephemeral_ipv4_network_errors_invalid_mask_prefix(self, m_subp):
"""Raise an error when prefix_or_mask is not a netmask or prefix."""
params = {
'interface': 'eth0', 'ip': '192.168.2.2',