summaryrefslogtreecommitdiff
path: root/cloudinit/net
diff options
context:
space:
mode:
Diffstat (limited to 'cloudinit/net')
-rw-r--r--cloudinit/net/__init__.py51
-rw-r--r--cloudinit/net/dhcp.py163
-rw-r--r--cloudinit/net/eni.py3
-rw-r--r--cloudinit/net/netplan.py40
-rw-r--r--cloudinit/net/network_state.py102
-rw-r--r--cloudinit/net/sysconfig.py6
-rw-r--r--cloudinit/net/tests/test_dhcp.py260
-rw-r--r--cloudinit/net/tests/test_init.py4
8 files changed, 550 insertions, 79 deletions
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index 46cb9c85..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'}]}
@@ -511,21 +515,7 @@ def get_interfaces_by_mac():
Bridges and any devices that have a 'stolen' mac are excluded."""
ret = {}
- devs = get_devicelist()
- 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
+ for name, mac, _driver, _devid in get_interfaces():
if mac in ret:
raise RuntimeError(
"duplicate mac found! both '%s' and '%s' have mac '%s'" %
@@ -599,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..0cba7032
--- /dev/null
+++ b/cloudinit/net/dhcp.py
@@ -0,0 +1,163 @@
+# 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 configobj
+import logging
+import os
+import re
+
+from cloudinit.net import find_fallback_nic, get_devicelist
+from cloudinit import temp_utils
+from cloudinit import util
+from six import StringIO
+
+LOG = logging.getLogger(__name__)
+
+NETWORKD_LEASES_DIR = '/run/systemd/netif/leases'
+
+
+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 temp_utils.tempdir(prefix='cloud-init-dhcp-', needs_exe=True) as tdir:
+ # Use /var/tmp because /run/cloud-init/tmp is mounted noexec
+ return dhcp_discovery(dhclient_path, nic, tdir)
+
+
+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)
+
+
+def networkd_parse_lease(content):
+ """Parse a systemd lease file content as in /run/systemd/netif/leases/
+
+ Parse this (almost) ini style file even though it says:
+ # This is private data. Do not parse.
+
+ Simply return a dictionary of key/values."""
+
+ return dict(configobj.ConfigObj(StringIO(content), list_values=False))
+
+
+def networkd_load_leases(leases_d=None):
+ """Return a dictionary of dictionaries representing each lease
+ found in lease_d.i
+
+ The top level key will be the filename, which is typically the ifindex."""
+
+ if leases_d is None:
+ leases_d = NETWORKD_LEASES_DIR
+
+ ret = {}
+ if not os.path.isdir(leases_d):
+ return ret
+ for lfile in os.listdir(leases_d):
+ ret[lfile] = networkd_parse_lease(
+ util.load_file(os.path.join(leases_d, lfile)))
+ return ret
+
+
+def networkd_get_option_from_leases(keyname, leases_d=None):
+ if leases_d is None:
+ leases_d = NETWORKD_LEASES_DIR
+ leases = networkd_load_leases(leases_d=leases_d)
+ for ifindex, data in sorted(leases.items()):
+ if data.get(keyname):
+ return data[keyname]
+ return None
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py
index bb80ec02..c6a71d16 100644
--- a/cloudinit/net/eni.py
+++ b/cloudinit/net/eni.py
@@ -95,6 +95,9 @@ def _iface_add_attrs(iface, index):
ignore_map.append('mac_address')
for key, value in iface.items():
+ # convert bool to string for eni
+ if type(value) == bool:
+ value = 'on' if iface[key] else 'off'
if not value or key in ignore_map:
continue
if key in multiline_keys:
diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py
index 9f35b72b..d3788af8 100644
--- a/cloudinit/net/netplan.py
+++ b/cloudinit/net/netplan.py
@@ -4,7 +4,7 @@ import copy
import os
from . import renderer
-from .network_state import subnet_is_ipv6
+from .network_state import subnet_is_ipv6, NET_CONFIG_TO_V2
from cloudinit import log as logging
from cloudinit import util
@@ -27,31 +27,6 @@ network:
"""
LOG = logging.getLogger(__name__)
-NET_CONFIG_TO_V2 = {
- 'bond': {'bond-ad-select': 'ad-select',
- 'bond-arp-interval': 'arp-interval',
- 'bond-arp-ip-target': 'arp-ip-target',
- 'bond-arp-validate': 'arp-validate',
- 'bond-downdelay': 'down-delay',
- 'bond-fail-over-mac': 'fail-over-mac-policy',
- 'bond-lacp-rate': 'lacp-rate',
- 'bond-miimon': 'mii-monitor-interval',
- 'bond-min-links': 'min-links',
- 'bond-mode': 'mode',
- 'bond-num-grat-arp': 'gratuitious-arp',
- 'bond-primary-reselect': 'primary-reselect-policy',
- 'bond-updelay': 'up-delay',
- 'bond-xmit-hash-policy': 'transmit-hash-policy'},
- 'bridge': {'bridge_ageing': 'ageing-time',
- 'bridge_bridgeprio': 'priority',
- 'bridge_fd': 'forward-delay',
- 'bridge_gcint': None,
- 'bridge_hello': 'hello-time',
- 'bridge_maxage': 'max-age',
- 'bridge_maxwait': None,
- 'bridge_pathcost': 'path-cost',
- 'bridge_portprio': None,
- 'bridge_waitport': None}}
def _get_params_dict_by_match(config, match):
@@ -247,6 +222,14 @@ class Renderer(renderer.Renderer):
util.subp(cmd, capture=True)
def _render_content(self, network_state):
+
+ # if content already in netplan format, pass it back
+ if network_state.version == 2:
+ LOG.debug('V2 to V2 passthrough')
+ return util.yaml_dumps({'network': network_state.config},
+ explicit_start=False,
+ explicit_end=False)
+
ethernets = {}
wifis = {}
bridges = {}
@@ -261,9 +244,9 @@ class Renderer(renderer.Renderer):
for config in network_state.iter_interfaces():
ifname = config.get('name')
- # filter None entries up front so we can do simple if key in dict
+ # filter None (but not False) entries up front
ifcfg = dict((key, value) for (key, value) in config.items()
- if value)
+ if value is not None)
if_type = ifcfg.get('type')
if if_type == 'physical':
@@ -335,6 +318,7 @@ class Renderer(renderer.Renderer):
(port, cost) = costval.split()
newvalue[port] = int(cost)
br_config.update({newname: newvalue})
+
if len(br_config) > 0:
bridge.update({'parameters': br_config})
_extract_addresses(ifcfg, bridge)
diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
index 87a7222d..0e830ee8 100644
--- a/cloudinit/net/network_state.py
+++ b/cloudinit/net/network_state.py
@@ -23,6 +23,34 @@ NETWORK_V2_KEY_FILTER = [
'match', 'mtu', 'nameservers', 'renderer', 'set-name', 'wakeonlan'
]
+NET_CONFIG_TO_V2 = {
+ 'bond': {'bond-ad-select': 'ad-select',
+ 'bond-arp-interval': 'arp-interval',
+ 'bond-arp-ip-target': 'arp-ip-target',
+ 'bond-arp-validate': 'arp-validate',
+ 'bond-downdelay': 'down-delay',
+ 'bond-fail-over-mac': 'fail-over-mac-policy',
+ 'bond-lacp-rate': 'lacp-rate',
+ 'bond-miimon': 'mii-monitor-interval',
+ 'bond-min-links': 'min-links',
+ 'bond-mode': 'mode',
+ 'bond-num-grat-arp': 'gratuitious-arp',
+ 'bond-primary': 'primary',
+ 'bond-primary-reselect': 'primary-reselect-policy',
+ 'bond-updelay': 'up-delay',
+ 'bond-xmit-hash-policy': 'transmit-hash-policy'},
+ 'bridge': {'bridge_ageing': 'ageing-time',
+ 'bridge_bridgeprio': 'priority',
+ 'bridge_fd': 'forward-delay',
+ 'bridge_gcint': None,
+ 'bridge_hello': 'hello-time',
+ 'bridge_maxage': 'max-age',
+ 'bridge_maxwait': None,
+ 'bridge_pathcost': 'path-cost',
+ 'bridge_portprio': None,
+ 'bridge_stp': 'stp',
+ 'bridge_waitport': None}}
+
def parse_net_config_data(net_config, skip_broken=True):
"""Parses the config, returns NetworkState object
@@ -120,6 +148,10 @@ class NetworkState(object):
self.use_ipv6 = network_state.get('use_ipv6', False)
@property
+ def config(self):
+ return self._network_state['config']
+
+ @property
def version(self):
return self._version
@@ -166,12 +198,14 @@ class NetworkStateInterpreter(object):
'search': [],
},
'use_ipv6': False,
+ 'config': None,
}
def __init__(self, version=NETWORK_STATE_VERSION, config=None):
self._version = version
self._config = config
self._network_state = copy.deepcopy(self.initial_network_state)
+ self._network_state['config'] = config
self._parsed = False
@property
@@ -432,6 +466,18 @@ class NetworkStateInterpreter(object):
for param, val in command.get('params', {}).items():
iface.update({param: val})
+ # convert value to boolean
+ bridge_stp = iface.get('bridge_stp')
+ if bridge_stp is not None and type(bridge_stp) != bool:
+ if bridge_stp in ['on', '1', 1]:
+ bridge_stp = True
+ elif bridge_stp in ['off', '0', 0]:
+ bridge_stp = False
+ else:
+ raise ValueError("Cannot convert bridge_stp value"
+ "(%s) to boolean", bridge_stp)
+ iface.update({'bridge_stp': bridge_stp})
+
interfaces.update({iface['name']: iface})
@ensure_command_keys(['address'])
@@ -460,12 +506,15 @@ class NetworkStateInterpreter(object):
v2_command = {
bond0: {
'interfaces': ['interface0', 'interface1'],
- 'miimon': 100,
- 'mode': '802.3ad',
- 'xmit_hash_policy': 'layer3+4'},
+ 'parameters': {
+ 'mii-monitor-interval': 100,
+ 'mode': '802.3ad',
+ 'xmit_hash_policy': 'layer3+4'}},
bond1: {
'bond-slaves': ['interface2', 'interface7'],
- 'mode': 1
+ 'parameters': {
+ 'mode': 1,
+ }
}
}
@@ -489,8 +538,8 @@ class NetworkStateInterpreter(object):
v2_command = {
br0: {
'interfaces': ['interface0', 'interface1'],
- 'fd': 0,
- 'stp': 'off',
+ 'forward-delay': 0,
+ 'stp': False,
'maxwait': 0,
}
}
@@ -554,6 +603,7 @@ class NetworkStateInterpreter(object):
if not mac_address:
LOG.debug('NetworkState Version2: missing "macaddress" info '
'in config entry: %s: %s', eth, str(cfg))
+ phy_cmd.update({'mac_address': mac_address})
for key in ['mtu', 'match', 'wakeonlan']:
if key in cfg:
@@ -598,8 +648,8 @@ class NetworkStateInterpreter(object):
self.handle_vlan(vlan_cmd)
def handle_wifis(self, command):
- raise NotImplementedError("NetworkState V2: "
- "Skipping wifi configuration")
+ LOG.warning('Wifi configuration is only available to distros with'
+ 'netplan rendering support.')
def _v2_common(self, cfg):
LOG.debug('v2_common: handling config:\n%s', cfg)
@@ -616,6 +666,11 @@ class NetworkStateInterpreter(object):
def _handle_bond_bridge(self, command, cmd_type=None):
"""Common handler for bond and bridge types"""
+
+ # inverse mapping for v2 keynames to v1 keynames
+ v2key_to_v1 = dict((v, k) for k, v in
+ NET_CONFIG_TO_V2.get(cmd_type).items())
+
for item_name, item_cfg in command.items():
item_params = dict((key, value) for (key, value) in
item_cfg.items() if key not in
@@ -624,14 +679,20 @@ class NetworkStateInterpreter(object):
'type': cmd_type,
'name': item_name,
cmd_type + '_interfaces': item_cfg.get('interfaces'),
- 'params': item_params,
+ 'params': dict((v2key_to_v1[k], v) for k, v in
+ item_params.get('parameters', {}).items())
}
subnets = self._v2_to_v1_ipcfg(item_cfg)
if len(subnets) > 0:
v1_cmd.update({'subnets': subnets})
- LOG.debug('v2(%ss) -> v1(%s):\n%s', cmd_type, cmd_type, v1_cmd)
- self.handle_bridge(v1_cmd)
+ LOG.debug('v2(%s) -> v1(%s):\n%s', cmd_type, cmd_type, v1_cmd)
+ if cmd_type == "bridge":
+ self.handle_bridge(v1_cmd)
+ elif cmd_type == "bond":
+ self.handle_bond(v1_cmd)
+ else:
+ raise ValueError('Unknown command type: %s', cmd_type)
def _v2_to_v1_ipcfg(self, cfg):
"""Common ipconfig extraction from v2 to v1 subnets array."""
@@ -651,12 +712,6 @@ class NetworkStateInterpreter(object):
'address': address,
}
- routes = []
- for route in cfg.get('routes', []):
- routes.append(_normalize_route(
- {'address': route.get('to'), 'gateway': route.get('via')}))
- subnet['routes'] = routes
-
if ":" in address:
if 'gateway6' in cfg and gateway6 is None:
gateway6 = cfg.get('gateway6')
@@ -667,6 +722,17 @@ class NetworkStateInterpreter(object):
subnet.update({'gateway': gateway4})
subnets.append(subnet)
+
+ routes = []
+ for route in cfg.get('routes', []):
+ routes.append(_normalize_route(
+ {'destination': route.get('to'), 'gateway': route.get('via')}))
+
+ # v2 routes are bound to the interface, in v1 we add them under
+ # the first subnet since there isn't an equivalent interface level.
+ if len(subnets) and len(routes):
+ subnets[0]['routes'] = routes
+
return subnets
@@ -721,7 +787,7 @@ def _normalize_net_keys(network, address_keys=()):
elif netmask:
prefix = mask_to_net_prefix(netmask)
elif 'prefix' in net:
- prefix = int(prefix)
+ prefix = int(net['prefix'])
else:
prefix = 64 if ipv6 else 24
diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
index a550f97c..f5727969 100644
--- a/cloudinit/net/sysconfig.py
+++ b/cloudinit/net/sysconfig.py
@@ -484,7 +484,11 @@ class Renderer(renderer.Renderer):
content.add_nameserver(nameserver)
for searchdomain in network_state.dns_searchdomains:
content.add_search_domain(searchdomain)
- return "\n".join([_make_header(';'), str(content)])
+ header = _make_header(';')
+ content_str = str(content)
+ if not content_str.startswith(header):
+ content_str = header + '\n' + content_str
+ return content_str
@staticmethod
def _render_networkmanager_conf(network_state):
diff --git a/cloudinit/net/tests/test_dhcp.py b/cloudinit/net/tests/test_dhcp.py
new file mode 100644
index 00000000..1c1f504a
--- /dev/null
+++ b/cloudinit/net/tests/test_dhcp.py
@@ -0,0 +1,260 @@
+# 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, networkd_load_leases)
+from cloudinit.util import ensure_file, write_file
+from cloudinit.tests.helpers import CiTestCase, wrap_and_call, populate_dir
+
+
+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.temp_utils.os.getuid')
+ @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_fback, m_which, m_dhcp, m_uid):
+ """maybe_perform_dhcp_discovery passes tmpdir to dhcp_discovery."""
+ m_uid.return_value = 0 # Fake root user for tmpdir
+ m_fback.return_value = 'eth9'
+ m_which.return_value = '/sbin/dhclient'
+ m_dhcp.return_value = {'address': '192.168.2.2'}
+ retval = wrap_and_call(
+ 'cloudinit.temp_utils',
+ {'_TMPDIR': {'new': None},
+ 'os.getuid': 0},
+ maybe_perform_dhcp_discovery)
+ self.assertEqual({'address': '192.168.2.2'}, retval)
+ self.assertEqual(
+ 1, m_dhcp.call_count, 'dhcp_discovery not called once')
+ call = m_dhcp.call_args_list[0]
+ self.assertEqual('/sbin/dhclient', call[0][0])
+ self.assertEqual('eth9', call[0][1])
+ self.assertIn('/var/tmp/cloud-init/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)])
+
+
+class TestSystemdParseLeases(CiTestCase):
+
+ lxd_lease = dedent("""\
+ # This is private data. Do not parse.
+ ADDRESS=10.75.205.242
+ NETMASK=255.255.255.0
+ ROUTER=10.75.205.1
+ SERVER_ADDRESS=10.75.205.1
+ NEXT_SERVER=10.75.205.1
+ BROADCAST=10.75.205.255
+ T1=1580
+ T2=2930
+ LIFETIME=3600
+ DNS=10.75.205.1
+ DOMAINNAME=lxd
+ HOSTNAME=a1
+ CLIENTID=ffe617693400020000ab110c65a6a0866931c2
+ """)
+
+ lxd_parsed = {
+ 'ADDRESS': '10.75.205.242',
+ 'NETMASK': '255.255.255.0',
+ 'ROUTER': '10.75.205.1',
+ 'SERVER_ADDRESS': '10.75.205.1',
+ 'NEXT_SERVER': '10.75.205.1',
+ 'BROADCAST': '10.75.205.255',
+ 'T1': '1580',
+ 'T2': '2930',
+ 'LIFETIME': '3600',
+ 'DNS': '10.75.205.1',
+ 'DOMAINNAME': 'lxd',
+ 'HOSTNAME': 'a1',
+ 'CLIENTID': 'ffe617693400020000ab110c65a6a0866931c2',
+ }
+
+ azure_lease = dedent("""\
+ # This is private data. Do not parse.
+ ADDRESS=10.132.0.5
+ NETMASK=255.255.255.255
+ ROUTER=10.132.0.1
+ SERVER_ADDRESS=169.254.169.254
+ NEXT_SERVER=10.132.0.1
+ MTU=1460
+ T1=43200
+ T2=75600
+ LIFETIME=86400
+ DNS=169.254.169.254
+ NTP=169.254.169.254
+ DOMAINNAME=c.ubuntu-foundations.internal
+ DOMAIN_SEARCH_LIST=c.ubuntu-foundations.internal google.internal
+ HOSTNAME=tribaal-test-171002-1349.c.ubuntu-foundations.internal
+ ROUTES=10.132.0.1/32,0.0.0.0 0.0.0.0/0,10.132.0.1
+ CLIENTID=ff405663a200020000ab11332859494d7a8b4c
+ OPTION_245=624c3620
+ """)
+
+ azure_parsed = {
+ 'ADDRESS': '10.132.0.5',
+ 'NETMASK': '255.255.255.255',
+ 'ROUTER': '10.132.0.1',
+ 'SERVER_ADDRESS': '169.254.169.254',
+ 'NEXT_SERVER': '10.132.0.1',
+ 'MTU': '1460',
+ 'T1': '43200',
+ 'T2': '75600',
+ 'LIFETIME': '86400',
+ 'DNS': '169.254.169.254',
+ 'NTP': '169.254.169.254',
+ 'DOMAINNAME': 'c.ubuntu-foundations.internal',
+ 'DOMAIN_SEARCH_LIST': 'c.ubuntu-foundations.internal google.internal',
+ 'HOSTNAME': 'tribaal-test-171002-1349.c.ubuntu-foundations.internal',
+ 'ROUTES': '10.132.0.1/32,0.0.0.0 0.0.0.0/0,10.132.0.1',
+ 'CLIENTID': 'ff405663a200020000ab11332859494d7a8b4c',
+ 'OPTION_245': '624c3620'}
+
+ def setUp(self):
+ super(TestSystemdParseLeases, self).setUp()
+ self.lease_d = self.tmp_dir()
+
+ def test_no_leases_returns_empty_dict(self):
+ """A leases dir with no lease files should return empty dictionary."""
+ self.assertEqual({}, networkd_load_leases(self.lease_d))
+
+ def test_no_leases_dir_returns_empty_dict(self):
+ """A non-existing leases dir should return empty dict."""
+ enodir = os.path.join(self.lease_d, 'does-not-exist')
+ self.assertEqual({}, networkd_load_leases(enodir))
+
+ def test_single_leases_file(self):
+ """A leases dir with one leases file."""
+ populate_dir(self.lease_d, {'2': self.lxd_lease})
+ self.assertEqual(
+ {'2': self.lxd_parsed}, networkd_load_leases(self.lease_d))
+
+ def test_single_azure_leases_file(self):
+ """On Azure, option 245 should be present, verify it specifically."""
+ populate_dir(self.lease_d, {'1': self.azure_lease})
+ self.assertEqual(
+ {'1': self.azure_parsed}, networkd_load_leases(self.lease_d))
+
+ def test_multiple_files(self):
+ """Multiple leases files on azure with one found return that value."""
+ self.maxDiff = None
+ populate_dir(self.lease_d, {'1': self.azure_lease,
+ '9': self.lxd_lease})
+ self.assertEqual({'1': self.azure_parsed, '9': self.lxd_parsed},
+ networkd_load_leases(self.lease_d))
diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py
index 272a6ebd..8cb4114e 100644
--- a/cloudinit/net/tests/test_init.py
+++ b/cloudinit/net/tests/test_init.py
@@ -7,7 +7,7 @@ import os
import cloudinit.net as net
from cloudinit.util import ensure_file, write_file, ProcessExecutionError
-from tests.unittests.helpers import CiTestCase
+from cloudinit.tests.helpers import CiTestCase
class TestSysDevPath(CiTestCase):
@@ -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',