diff options
author | Scott Moser <smoser@brickies.net> | 2017-07-31 14:46:00 -0400 |
---|---|---|
committer | Scott Moser <smoser@brickies.net> | 2017-07-31 14:46:00 -0400 |
commit | 19c248d009af6a7cff26fbb2febf5c958987084d (patch) | |
tree | 521cc4c8cd303fd7a9eb56bc4eb5975c48996298 /cloudinit/net | |
parent | f47c7ac027fc905ca7f6bee776007e2a922c117e (diff) | |
parent | e586fe35a692b7519000005c8024ebd2bcbc82e0 (diff) | |
download | vyos-cloud-init-19c248d009af6a7cff26fbb2febf5c958987084d.tar.gz vyos-cloud-init-19c248d009af6a7cff26fbb2febf5c958987084d.zip |
merge from master at 0.7.9-233-ge586fe35
Diffstat (limited to 'cloudinit/net')
-rw-r--r-- | cloudinit/net/__init__.py | 315 | ||||
-rw-r--r-- | cloudinit/net/eni.py | 46 | ||||
-rw-r--r-- | cloudinit/net/netplan.py | 17 | ||||
-rw-r--r-- | cloudinit/net/network_state.py | 244 | ||||
-rw-r--r-- | cloudinit/net/renderer.py | 8 | ||||
-rw-r--r-- | cloudinit/net/sysconfig.py | 155 | ||||
-rw-r--r-- | cloudinit/net/tests/__init__.py | 0 | ||||
-rw-r--r-- | cloudinit/net/tests/test_init.py | 522 | ||||
-rw-r--r-- | cloudinit/net/udev.py | 7 |
9 files changed, 1146 insertions, 168 deletions
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py index 8c6cd057..46cb9c85 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__) @@ -17,8 +18,24 @@ SYS_CLASS_NET = "/sys/class/net/" DEFAULT_PRIMARY_INTERFACE = 'eth0' +def _natural_sort_key(s, _nsre=re.compile('([0-9]+)')): + """Sorting for Humans: natural sort order. Can be use as the key to sort + functions. + This will sort ['eth0', 'ens3', 'ens10', 'ens12', 'ens8', 'ens0'] as + ['ens0', 'ens3', 'ens8', 'ens10', 'ens12', 'eth0'] instead of the simple + python way which will produce ['ens0', 'ens10', 'ens12', 'ens3', 'ens8', + 'eth0'].""" + return [int(text) if text.isdigit() else text.lower() + for text in re.split(_nsre, s)] + + +def get_sys_class_path(): + """Simple function to return the global SYS_CLASS_NET.""" + return SYS_CLASS_NET + + def sys_dev_path(devname, path=""): - return SYS_CLASS_NET + devname + "/" + path + return get_sys_class_path() + devname + "/" + path def read_sys_net(devname, path, translate=None, @@ -66,7 +83,7 @@ def read_sys_net_int(iface, field): return None try: return int(val) - except TypeError: + except ValueError: return None @@ -86,6 +103,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() @@ -113,8 +134,35 @@ 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) + try: + devs = os.listdir(get_sys_class_path()) + except OSError as e: + if e.errno == errno.ENOENT: + devs = [] + else: + raise + return devs class ParserError(Exception): @@ -127,12 +175,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 @@ -144,6 +201,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) @@ -169,7 +229,7 @@ def generate_fallback_config(): # if eth0 exists use it above anything else, otherwise get the interface # that we can read 'first' (using the sorted defintion of first). - names = list(sorted(potential_interfaces)) + names = list(sorted(potential_interfaces, key=_natural_sort_key)) if DEFAULT_PRIMARY_INTERFACE in names: names.remove(DEFAULT_PRIMARY_INTERFACE) names.insert(0, DEFAULT_PRIMARY_INTERFACE) @@ -183,9 +243,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 @@ -206,10 +275,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) @@ -234,15 +309,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 (in lower case) + '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.lower(), + 'name': name, + 'up': is_up(name), + } if check_downable: nmatch = re.compile(r"[0-9]+:\s+(\w+)[@:]") @@ -254,11 +341,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, @@ -271,15 +358,17 @@ 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 + if cur.get('mac'): + cur['mac'] = cur['mac'].lower() + 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) @@ -293,14 +382,50 @@ 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: + if mac: + mac = mac.lower() 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 @@ -340,13 +465,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} @@ -385,14 +510,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): @@ -415,6 +534,126 @@ 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.""" + 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 + ret.append((name, mac, device_driver(name), device_devid(name))) + return ret + + +class EphemeralIPv4Network(object): + """Context manager which sets up temporary static network configuration. + + No operations are performed if the provided interface is already connected. + If unconnected, bring up the interface with valid ip, prefix and broadcast. + If router is provided setup a default route for that interface. Upon + context exit, clean up the interface leaving no configuration behind. + """ + + def __init__(self, interface, ip, prefix_or_mask, broadcast, router=None): + """Setup context manager and validate call signature. + + @param interface: Name of the network interface to bring up. + @param ip: IP address to assign to the interface. + @param prefix_or_mask: Either netmask of the format X.X.X.X or an int + prefix. + @param broadcast: Broadcast address for the IPv4 network. + @param router: Optionally the default gateway IP. + """ + if not all([interface, ip, prefix_or_mask, broadcast]): + raise ValueError( + 'Cannot init network on {0} with {1}/{2} and bcast {3}'.format( + interface, ip, prefix_or_mask, broadcast)) + try: + self.prefix = mask_to_net_prefix(prefix_or_mask) + except ValueError as e: + raise ValueError( + 'Cannot setup network: {0}'.format(e)) + self.interface = interface + self.ip = ip + self.broadcast = broadcast + self.router = router + self.cleanup_cmds = [] # List of commands to run to cleanup state. + + def __enter__(self): + """Perform ephemeral network setup if interface is not connected.""" + self._bringup_device() + if self.router: + self._bringup_router() + + def __exit__(self, excp_type, excp_value, excp_traceback): + for cmd in self.cleanup_cmds: + util.subp(cmd, capture=True) + + def _delete_address(self, address, prefix): + """Perform the ip command to remove the specified address.""" + util.subp( + ['ip', '-family', 'inet', 'addr', 'del', + '%s/%s' % (address, prefix), 'dev', self.interface], + capture=True) + + def _bringup_device(self): + """Perform the ip comands to fully setup the device.""" + cidr = '{0}/{1}'.format(self.ip, self.prefix) + LOG.debug( + 'Attempting setup of ephemeral network on %s with %s brd %s', + self.interface, cidr, self.broadcast) + try: + util.subp( + ['ip', '-family', 'inet', 'addr', 'add', cidr, 'broadcast', + self.broadcast, 'dev', self.interface], + capture=True, update_env={'LANG': 'C'}) + except util.ProcessExecutionError as e: + if "File exists" not in e.stderr: + raise + LOG.debug( + 'Skip ephemeral network setup, %s already has address %s', + self.interface, self.ip) + else: + # Address creation success, bring up device and queue cleanup + util.subp( + ['ip', '-family', 'inet', 'link', 'set', 'dev', self.interface, + 'up'], capture=True) + self.cleanup_cmds.append( + ['ip', '-family', 'inet', 'link', 'set', 'dev', self.interface, + 'down']) + self.cleanup_cmds.append( + ['ip', '-family', 'inet', 'addr', 'del', cidr, 'dev', + self.interface]) + + def _bringup_router(self): + """Perform the ip commands to fully setup the router if needed.""" + # Check if a default route exists and exit if it does + out, _ = util.subp(['ip', 'route', 'show', '0.0.0.0/0'], capture=True) + if 'default' in out: + LOG.debug( + 'Skip ephemeral route setup. %s already has default route: %s', + self.interface, out.strip()) + return + util.subp( + ['ip', '-4', 'route', 'add', 'default', 'via', self.router, + 'dev', self.interface], capture=True) + self.cleanup_cmds.insert( + 0, ['ip', '-4', 'route', 'del', 'default', 'dev', self.interface]) + + class RendererNotFoundError(RuntimeError): pass diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py index 9819d4f5..bb80ec02 100644 --- a/cloudinit/net/eni.py +++ b/cloudinit/net/eni.py @@ -46,6 +46,10 @@ def _iface_add_subnet(iface, subnet): 'dns_nameservers', ] for key, value in subnet.items(): + if key == 'netmask': + continue + if key == 'address': + value = "%s/%s" % (subnet['address'], subnet['prefix']) if value and key in valid_map: if type(value) == list: value = " ".join(value) @@ -68,6 +72,8 @@ def _iface_add_attrs(iface, index): content = [] ignore_map = [ 'control', + 'device_id', + 'driver', 'index', 'inet', 'mode', @@ -75,6 +81,15 @@ def _iface_add_attrs(iface, index): 'subnets', 'type', ] + + # The following parameters require repetitive entries of the key for + # each of the values + multiline_keys = [ + 'bridge_pathcost', + 'bridge_portprio', + 'bridge_waitport', + ] + renames = {'mac_address': 'hwaddress'} if iface['type'] not in ['bond', 'bridge', 'vlan']: ignore_map.append('mac_address') @@ -82,6 +97,10 @@ def _iface_add_attrs(iface, index): for key, value in iface.items(): if not value or key in ignore_map: continue + if key in multiline_keys: + for v in value: + content.append(" {0} {1}".format(renames.get(key, key), v)) + continue if type(value) == list: value = " ".join(value) content.append(" {0} {1}".format(renames.get(key, key), value)) @@ -304,8 +323,6 @@ class Renderer(renderer.Renderer): config = {} self.eni_path = config.get('eni_path', 'etc/network/interfaces') self.eni_header = config.get('eni_header', None) - self.links_path_prefix = config.get( - 'links_path_prefix', 'etc/systemd/network/50-cloud-init-') self.netrules_path = config.get( 'netrules_path', 'etc/udev/rules.d/70-persistent-net.rules') @@ -338,7 +355,7 @@ class Renderer(renderer.Renderer): default_gw = " default gw %s" % route['gateway'] content.append(up + default_gw + or_true) content.append(down + default_gw + or_true) - elif route['network'] == '::' and route['netmask'] == 0: + elif route['network'] == '::' and route['prefix'] == 0: # ipv6! default_gw = " -A inet6 default gw %s" % route['gateway'] content.append(up + default_gw + or_true) @@ -451,28 +468,6 @@ class Renderer(renderer.Renderer): util.write_file(netrules, self._render_persistent_net(network_state)) - if self.links_path_prefix: - self._render_systemd_links(target, network_state, - links_prefix=self.links_path_prefix) - - def _render_systemd_links(self, target, network_state, links_prefix): - fp_prefix = util.target_path(target, links_prefix) - for f in glob.glob(fp_prefix + "*"): - os.unlink(f) - for iface in network_state.iter_interfaces(): - if (iface['type'] == 'physical' and 'name' in iface and - iface.get('mac_address')): - fname = fp_prefix + iface['name'] + ".link" - content = "\n".join([ - "[Match]", - "MACAddress=" + iface['mac_address'], - "", - "[Link]", - "Name=" + iface['name'], - "" - ]) - util.write_file(fname, content) - def network_state_to_eni(network_state, header=None, render_hwaddress=False): # render the provided network state, return a string of equivalent eni @@ -480,7 +475,6 @@ def network_state_to_eni(network_state, header=None, render_hwaddress=False): renderer = Renderer(config={ 'eni_path': eni_path, 'eni_header': header, - 'links_path_prefix': None, 'netrules_path': None, }) if not header: diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py index a715f3b0..9f35b72b 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 mask2cidr, subnet_is_ipv6 +from .network_state import subnet_is_ipv6 from cloudinit import log as logging from cloudinit import util @@ -118,10 +118,9 @@ def _extract_addresses(config, entry): sn_type += '4' entry.update({sn_type: True}) elif sn_type in ['static']: - addr = '%s' % subnet.get('address') - netmask = subnet.get('netmask') - if netmask and '/' not in addr: - addr += '/%s' % mask2cidr(netmask) + addr = "%s" % subnet.get('address') + if 'prefix' in subnet: + addr += "/%d" % subnet.get('prefix') if 'gateway' in subnet and subnet.get('gateway'): gateway = subnet.get('gateway') if ":" in gateway: @@ -138,9 +137,8 @@ def _extract_addresses(config, entry): mtukey += '6' entry.update({mtukey: subnet.get('mtu')}) for route in subnet.get('routes', []): - network = route.get('network') - netmask = route.get('netmask') - to_net = '%s/%s' % (network, mask2cidr(netmask)) + to_net = "%s/%s" % (route.get('network'), + route.get('prefix')) route = { 'via': route.get('gateway'), 'to': to_net, @@ -211,7 +209,8 @@ class Renderer(renderer.Renderer): # check network state for version # if v2, then extract network_state.config # else render_v2_from_state - fpnplan = os.path.join(target, self.netplan_path) + fpnplan = os.path.join(util.target_path(target), self.netplan_path) + util.ensure_dir(os.path.dirname(fpnplan)) header = self.netplan_header if self.netplan_header else "" diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py index 9e9c05a0..87a7222d 100644 --- a/cloudinit/net/network_state.py +++ b/cloudinit/net/network_state.py @@ -289,19 +289,15 @@ class NetworkStateInterpreter(object): iface.update({param: val}) # convert subnet ipv6 netmask to cidr as needed - subnets = command.get('subnets') - if subnets: + subnets = _normalize_subnets(command.get('subnets')) + + # automatically set 'use_ipv6' if any addresses are ipv6 + if not self.use_ipv6: for subnet in subnets: - if subnet['type'] == 'static': - if ':' in subnet['address']: - self.use_ipv6 = True - if 'netmask' in subnet and ':' in subnet['address']: - subnet['netmask'] = mask2cidr(subnet['netmask']) - for route in subnet.get('routes', []): - if 'netmask' in route: - route['netmask'] = mask2cidr(route['netmask']) - elif subnet['type'].endswith('6'): + if (subnet.get('type').endswith('6') or + is_ipv6_addr(subnet.get('address'))): self.use_ipv6 = True + break iface.update({ 'name': command.get('name'), @@ -456,16 +452,7 @@ class NetworkStateInterpreter(object): @ensure_command_keys(['destination']) def handle_route(self, command): - routes = self._network_state.get('routes', []) - network, cidr = command['destination'].split("/") - netmask = cidr2mask(int(cidr)) - route = { - 'network': network, - 'netmask': netmask, - 'gateway': command.get('gateway'), - 'metric': command.get('metric'), - } - routes.append(route) + self._network_state['routes'].append(_normalize_route(command)) # V2 handlers def handle_bonds(self, command): @@ -666,18 +653,9 @@ class NetworkStateInterpreter(object): routes = [] for route in cfg.get('routes', []): - route_addr = route.get('to') - if "/" in route_addr: - route_addr, route_cidr = route_addr.split("/") - route_netmask = cidr2mask(route_cidr) - subnet_route = { - 'address': route_addr, - 'netmask': route_netmask, - 'gateway': route.get('via') - } - routes.append(subnet_route) - if len(routes) > 0: - subnet.update({'routes': 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: @@ -692,53 +670,219 @@ class NetworkStateInterpreter(object): return subnets +def _normalize_subnet(subnet): + # Prune all keys with None values. + subnet = copy.deepcopy(subnet) + normal_subnet = dict((k, v) for k, v in subnet.items() if v) + + if subnet.get('type') in ('static', 'static6'): + normal_subnet.update( + _normalize_net_keys(normal_subnet, address_keys=('address',))) + normal_subnet['routes'] = [_normalize_route(r) + for r in subnet.get('routes', [])] + return normal_subnet + + +def _normalize_net_keys(network, address_keys=()): + """Normalize dictionary network keys returning prefix and address keys. + + @param network: A dict of network-related definition containing prefix, + netmask and address_keys. + @param address_keys: A tuple of keys to search for representing the address + or cidr. The first address_key discovered will be used for + normalization. + + @returns: A dict containing normalized prefix and matching addr_key. + """ + net = dict((k, v) for k, v in network.items() if v) + addr_key = None + for key in address_keys: + if net.get(key): + addr_key = key + break + if not addr_key: + message = ( + 'No config network address keys [%s] found in %s' % + (','.join(address_keys), network)) + LOG.error(message) + raise ValueError(message) + + addr = net.get(addr_key) + ipv6 = is_ipv6_addr(addr) + netmask = net.get('netmask') + if "/" in addr: + addr_part, _, maybe_prefix = addr.partition("/") + net[addr_key] = addr_part + try: + prefix = int(maybe_prefix) + except ValueError: + # this supports input of <address>/255.255.255.0 + prefix = mask_to_net_prefix(maybe_prefix) + elif netmask: + prefix = mask_to_net_prefix(netmask) + elif 'prefix' in net: + prefix = int(prefix) + else: + prefix = 64 if ipv6 else 24 + + if 'prefix' in net and str(net['prefix']) != str(prefix): + LOG.warning("Overwriting existing 'prefix' with '%s' in " + "network info: %s", prefix, net) + net['prefix'] = prefix + + if ipv6: + # TODO: we could/maybe should add this back with the very uncommon + # 'netmask' for ipv6. We need a 'net_prefix_to_ipv6_mask' for that. + if 'netmask' in net: + del net['netmask'] + else: + net['netmask'] = net_prefix_to_ipv4_mask(net['prefix']) + + return net + + +def _normalize_route(route): + """normalize a route. + return a dictionary with only: + 'type': 'route' (only present if it was present in input) + 'network': the network portion of the route as a string. + 'prefix': the network prefix for address as an integer. + 'metric': integer metric (only if present in input). + 'netmask': netmask (string) equivalent to prefix iff network is ipv4. + """ + # Prune None-value keys. Specifically allow 0 (a valid metric). + normal_route = dict((k, v) for k, v in route.items() + if v not in ("", None)) + if 'destination' in normal_route: + normal_route['network'] = normal_route['destination'] + del normal_route['destination'] + + normal_route.update( + _normalize_net_keys( + normal_route, address_keys=('network', 'destination'))) + + metric = normal_route.get('metric') + if metric: + try: + normal_route['metric'] = int(metric) + except ValueError: + raise TypeError( + 'Route config metric {} is not an integer'.format(metric)) + return normal_route + + +def _normalize_subnets(subnets): + if not subnets: + subnets = [] + return [_normalize_subnet(s) for s in subnets] + + +def is_ipv6_addr(address): + if not address: + return False + return ":" in str(address) + + def subnet_is_ipv6(subnet): """Common helper for checking network_state subnets for ipv6.""" # 'static6' or 'dhcp6' if subnet['type'].endswith('6'): # This is a request for DHCPv6. return True - elif subnet['type'] == 'static' and ":" in subnet['address']: + elif subnet['type'] == 'static' and is_ipv6_addr(subnet.get('address')): return True return False -def cidr2mask(cidr): +def net_prefix_to_ipv4_mask(prefix): + """Convert a network prefix to an ipv4 netmask. + + This is the inverse of ipv4_mask_to_net_prefix. + 24 -> "255.255.255.0" + Also supports input as a string.""" + mask = [0, 0, 0, 0] - for i in list(range(0, cidr)): + for i in list(range(0, int(prefix))): idx = int(i / 8) mask[idx] = mask[idx] + (1 << (7 - i % 8)) return ".".join([str(x) for x in mask]) -def ipv4mask2cidr(mask): - if '.' not in mask: +def ipv4_mask_to_net_prefix(mask): + """Convert an ipv4 netmask into a network prefix length. + + If the input is already an integer or a string representation of + an integer, then int(mask) will be returned. + "255.255.255.0" => 24 + str(24) => 24 + "24" => 24 + """ + if isinstance(mask, int): return mask - return sum([bin(int(x)).count('1') for x in mask.split('.')]) + if isinstance(mask, six.string_types): + try: + return int(mask) + except ValueError: + pass + else: + raise TypeError("mask '%s' is not a string or int") + if '.' not in mask: + raise ValueError("netmask '%s' does not contain a '.'" % mask) -def ipv6mask2cidr(mask): - if ':' not in mask: + toks = mask.split(".") + if len(toks) != 4: + raise ValueError("netmask '%s' had only %d parts" % (mask, len(toks))) + + return sum([bin(int(x)).count('1') for x in toks]) + + +def ipv6_mask_to_net_prefix(mask): + """Convert an ipv6 netmask (very uncommon) or prefix (64) to prefix. + + If 'mask' is an integer or string representation of one then + int(mask) will be returned. + """ + + if isinstance(mask, int): return mask + if isinstance(mask, six.string_types): + try: + return int(mask) + except ValueError: + pass + else: + raise TypeError("mask '%s' is not a string or int") + + if ':' not in mask: + raise ValueError("mask '%s' does not have a ':'") bitCount = [0, 0x8000, 0xc000, 0xe000, 0xf000, 0xf800, 0xfc00, 0xfe00, 0xff00, 0xff80, 0xffc0, 0xffe0, 0xfff0, 0xfff8, 0xfffc, 0xfffe, 0xffff] - cidr = 0 + prefix = 0 for word in mask.split(':'): if not word or int(word, 16) == 0: break - cidr += bitCount.index(int(word, 16)) + prefix += bitCount.index(int(word, 16)) + + return prefix - return cidr +def mask_to_net_prefix(mask): + """Return the network prefix for the netmask provided. -def mask2cidr(mask): - if ':' in str(mask): - return ipv6mask2cidr(mask) - elif '.' in str(mask): - return ipv4mask2cidr(mask) + Supports ipv4 or ipv6 netmasks.""" + try: + # if 'mask' is a prefix that is an integer. + # then just return it. + return int(mask) + except ValueError: + pass + if is_ipv6_addr(mask): + return ipv6_mask_to_net_prefix(mask) else: - return mask + return ipv4_mask_to_net_prefix(mask) + # vi: ts=4 expandtab diff --git a/cloudinit/net/renderer.py b/cloudinit/net/renderer.py index c68658dc..57652e27 100644 --- a/cloudinit/net/renderer.py +++ b/cloudinit/net/renderer.py @@ -20,6 +20,10 @@ def filter_by_name(match_name): return lambda iface: match_name == iface['name'] +def filter_by_attr(match_name): + return lambda iface: (match_name in iface and iface[match_name]) + + filter_by_physical = filter_by_type('physical') @@ -34,8 +38,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/sysconfig.py b/cloudinit/net/sysconfig.py index 58c5713f..a550f97c 100644 --- a/cloudinit/net/sysconfig.py +++ b/cloudinit/net/sysconfig.py @@ -5,11 +5,13 @@ import re import six +from cloudinit.distros.parsers import networkmanager_conf from cloudinit.distros.parsers import resolv_conf from cloudinit import util from . import renderer -from .network_state import subnet_is_ipv6 +from .network_state import ( + is_ipv6_addr, net_prefix_to_ipv4_mask, subnet_is_ipv6) def _make_header(sep='#'): @@ -26,11 +28,8 @@ def _make_header(sep='#'): def _is_default_route(route): - if route['network'] == '::' and route['netmask'] == 0: - return True - if route['network'] == '0.0.0.0' and route['netmask'] == '0.0.0.0': - return True - return False + default_nets = ('::', '0.0.0.0') + return route['prefix'] == 0 and route['network'] in default_nets def _quote_value(value): @@ -62,6 +61,9 @@ class ConfigMap(object): def __getitem__(self, key): return self._conf[key] + def __contains__(self, key): + return key in self._conf + def drop(self, key): self._conf.pop(key, None) @@ -153,9 +155,10 @@ class Route(ConfigMap): elif proto == "ipv6" and self.is_ipv6_route(address_value): netmask_value = str(self._conf['NETMASK' + index]) gateway_value = str(self._conf['GATEWAY' + index]) - buf.write("%s/%s via %s\n" % (address_value, - netmask_value, - gateway_value)) + buf.write("%s/%s via %s dev %s\n" % (address_value, + netmask_value, + gateway_value, + self._route_name)) return buf.getvalue() @@ -252,6 +255,9 @@ class Renderer(renderer.Renderer): self.netrules_path = config.get( 'netrules_path', 'etc/udev/rules.d/70-persistent-net.rules') self.dns_path = config.get('dns_path', 'etc/resolv.conf') + nm_conf_path = 'etc/NetworkManager/conf.d/99-cloud-init.conf' + self.networkmanager_conf_path = config.get('networkmanager_conf_path', + nm_conf_path) @classmethod def _render_iface_shared(cls, iface, iface_cfg): @@ -261,6 +267,9 @@ class Renderer(renderer.Renderer): for (old_key, new_key) in [('mac_address', 'HWADDR'), ('mtu', 'MTU')]: old_value = iface.get(old_key) if old_value is not None: + # only set HWADDR on physical interfaces + if old_key == 'mac_address' and iface['type'] != 'physical': + continue iface_cfg[new_key] = old_value @classmethod @@ -270,6 +279,7 @@ class Renderer(renderer.Renderer): # modifying base values according to subnets for i, subnet in enumerate(subnets, start=len(iface_cfg.children)): + mtu_key = 'MTU' subnet_type = subnet.get('type') if subnet_type == 'dhcp6': iface_cfg['IPV6INIT'] = True @@ -289,11 +299,20 @@ class Renderer(renderer.Renderer): # if iface_cfg['BOOTPROTO'] == 'none': # iface_cfg['BOOTPROTO'] = 'static' if subnet_is_ipv6(subnet): + mtu_key = 'IPV6_MTU' iface_cfg['IPV6INIT'] = True + if 'mtu' in subnet: + iface_cfg[mtu_key] = subnet['mtu'] + elif subnet_type == 'manual': + # If the subnet has an MTU setting, then ONBOOT=True + # to apply the setting + iface_cfg['ONBOOT'] = mtu_key in iface_cfg else: raise ValueError("Unknown subnet type '%s' found" " for interface '%s'" % (subnet_type, iface_cfg.name)) + if subnet.get('control') == 'manual': + iface_cfg['ONBOOT'] = False # set IPv4 and IPv6 static addresses ipv4_index = -1 @@ -307,38 +326,32 @@ class Renderer(renderer.Renderer): elif subnet_type == 'static': if subnet_is_ipv6(subnet): ipv6_index = ipv6_index + 1 - if 'netmask' in subnet and str(subnet['netmask']) != "": - ipv6_cidr = (subnet['address'] + - '/' + - str(subnet['netmask'])) - else: - ipv6_cidr = subnet['address'] + ipv6_cidr = "%s/%s" % (subnet['address'], subnet['prefix']) if ipv6_index == 0: iface_cfg['IPV6ADDR'] = ipv6_cidr elif ipv6_index == 1: iface_cfg['IPV6ADDR_SECONDARIES'] = ipv6_cidr else: - iface_cfg['IPV6ADDR_SECONDARIES'] = ( - iface_cfg['IPV6ADDR_SECONDARIES'] + - " " + ipv6_cidr) + iface_cfg['IPV6ADDR_SECONDARIES'] += " " + ipv6_cidr else: ipv4_index = ipv4_index + 1 - if ipv4_index == 0: - iface_cfg['IPADDR'] = subnet['address'] - if 'netmask' in subnet: - iface_cfg['NETMASK'] = subnet['netmask'] + suff = "" if ipv4_index == 0 else str(ipv4_index) + iface_cfg['IPADDR' + suff] = subnet['address'] + iface_cfg['NETMASK' + suff] = \ + net_prefix_to_ipv4_mask(subnet['prefix']) + + if 'gateway' in subnet: + iface_cfg['DEFROUTE'] = True + if is_ipv6_addr(subnet['gateway']): + iface_cfg['IPV6_DEFAULTGW'] = subnet['gateway'] else: - iface_cfg['IPADDR' + str(ipv4_index)] = \ - subnet['address'] - if 'netmask' in subnet: - iface_cfg['NETMASK' + str(ipv4_index)] = \ - subnet['netmask'] + iface_cfg['GATEWAY'] = subnet['gateway'] @classmethod def _render_subnet_routes(cls, iface_cfg, route_cfg, subnets): for i, subnet in enumerate(subnets, start=len(iface_cfg.children)): for route in subnet.get('routes', []): - is_ipv6 = subnet.get('ipv6') + is_ipv6 = subnet.get('ipv6') or is_ipv6_addr(route['gateway']) if _is_default_route(route): if ( @@ -360,7 +373,7 @@ class Renderer(renderer.Renderer): # also provided the default route? iface_cfg['DEFROUTE'] = True if 'gateway' in route: - if is_ipv6: + if is_ipv6 or is_ipv6_addr(route['gateway']): iface_cfg['IPV6_DEFAULTGW'] = route['gateway'] route_cfg.has_set_default_ipv6 = True else: @@ -372,11 +385,13 @@ class Renderer(renderer.Renderer): nm_key = 'NETMASK%s' % route_cfg.last_idx addr_key = 'ADDRESS%s' % route_cfg.last_idx route_cfg.last_idx += 1 - for (old_key, new_key) in [('gateway', gw_key), - ('netmask', nm_key), - ('network', addr_key)]: - if old_key in route: - route_cfg[new_key] = route[old_key] + # add default routes only to ifcfg files, not + # to route-* or route6-* + for (old_key, new_key) in [('gateway', gw_key), + ('netmask', nm_key), + ('network', addr_key)]: + if old_key in route: + route_cfg[new_key] = route[old_key] @classmethod def _render_bonding_opts(cls, iface_cfg, iface): @@ -409,24 +424,45 @@ class Renderer(renderer.Renderer): @classmethod def _render_bond_interfaces(cls, network_state, iface_contents): bond_filter = renderer.filter_by_type('bond') + slave_filter = renderer.filter_by_attr('bond-master') for iface in network_state.iter_interfaces(bond_filter): iface_name = iface['name'] iface_cfg = iface_contents[iface_name] cls._render_bonding_opts(iface_cfg, iface) - iface_master_name = iface['bond-master'] - iface_cfg['MASTER'] = iface_master_name - iface_cfg['SLAVE'] = True + # Ensure that the master interface (and any of its children) # are actually marked as being bond types... - master_cfg = iface_contents[iface_master_name] - master_cfgs = [master_cfg] - master_cfgs.extend(master_cfg.children) + master_cfgs = [iface_cfg] + master_cfgs.extend(iface_cfg.children) for master_cfg in master_cfgs: master_cfg['BONDING_MASTER'] = True master_cfg.kind = 'bond' - @staticmethod - def _render_vlan_interfaces(network_state, iface_contents): + if iface.get('mac_address'): + iface_cfg['MACADDR'] = iface.get('mac_address') + + iface_subnets = iface.get("subnets", []) + route_cfg = iface_cfg.routes + cls._render_subnets(iface_cfg, iface_subnets) + cls._render_subnet_routes(iface_cfg, route_cfg, iface_subnets) + + # iter_interfaces on network-state is not sorted to produce + # consistent numbers we need to sort. + bond_slaves = sorted( + [slave_iface['name'] for slave_iface in + network_state.iter_interfaces(slave_filter) + if slave_iface['bond-master'] == iface_name]) + + for index, bond_slave in enumerate(bond_slaves): + slavestr = 'BONDING_SLAVE%s' % index + iface_cfg[slavestr] = bond_slave + + slave_cfg = iface_contents[bond_slave] + slave_cfg['MASTER'] = iface_name + slave_cfg['SLAVE'] = True + + @classmethod + def _render_vlan_interfaces(cls, network_state, iface_contents): vlan_filter = renderer.filter_by_type('vlan') for iface in network_state.iter_interfaces(vlan_filter): iface_name = iface['name'] @@ -434,6 +470,11 @@ class Renderer(renderer.Renderer): iface_cfg['VLAN'] = True iface_cfg['PHYSDEV'] = iface_name[:iface_name.rfind('.')] + iface_subnets = iface.get("subnets", []) + route_cfg = iface_cfg.routes + cls._render_subnets(iface_cfg, iface_subnets) + cls._render_subnet_routes(iface_cfg, route_cfg, iface_subnets) + @staticmethod def _render_dns(network_state, existing_dns_path=None): content = resolv_conf.ResolvConf("") @@ -445,6 +486,21 @@ class Renderer(renderer.Renderer): content.add_search_domain(searchdomain) return "\n".join([_make_header(';'), str(content)]) + @staticmethod + def _render_networkmanager_conf(network_state): + content = networkmanager_conf.NetworkManagerConf("") + + # If DNS server information is provided, configure + # NetworkManager to not manage dns, so that /etc/resolv.conf + # does not get clobbered. + if network_state.dns_nameservers: + content.set_section_keypair('main', 'dns', 'none') + + if len(content) == 0: + return None + out = "".join([_make_header(), "\n", "\n".join(content.write()), "\n"]) + return out + @classmethod def _render_bridge_interfaces(cls, network_state, iface_contents): bridge_filter = renderer.filter_by_type('bridge') @@ -455,6 +511,10 @@ class Renderer(renderer.Renderer): for old_key, new_key in cls.bridge_opts_keys: if old_key in iface: iface_cfg[new_key] = iface[old_key] + + if iface.get('mac_address'): + iface_cfg['MACADDR'] = iface.get('mac_address') + # Is this the right key to get all the connected interfaces? for bridged_iface_name in iface.get('bridge_ports', []): # Ensure all bridged interfaces are correctly tagged @@ -465,6 +525,11 @@ class Renderer(renderer.Renderer): for bridge_cfg in bridged_cfgs: bridge_cfg['BRIDGE'] = iface_name + iface_subnets = iface.get("subnets", []) + route_cfg = iface_cfg.routes + cls._render_subnets(iface_cfg, iface_subnets) + cls._render_subnet_routes(iface_cfg, route_cfg, iface_subnets) + @classmethod def _render_sysconfig(cls, base_sysconf_dir, network_state): '''Given state, return /etc/sysconfig files + contents''' @@ -505,6 +570,12 @@ class Renderer(renderer.Renderer): resolv_content = self._render_dns(network_state, existing_dns_path=dns_path) util.write_file(dns_path, resolv_content, file_mode) + if self.networkmanager_conf_path: + nm_conf_path = util.target_path(target, + self.networkmanager_conf_path) + nm_conf_content = self._render_networkmanager_conf(network_state) + if nm_conf_content: + util.write_file(nm_conf_path, nm_conf_content, file_mode) if self.netrules_path: netrules_content = self._render_persistent_net(network_state) netrules_path = util.target_path(target, self.netrules_path) diff --git a/cloudinit/net/tests/__init__.py b/cloudinit/net/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /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 00000000..272a6ebd --- /dev/null +++ b/cloudinit/net/tests/test_init.py @@ -0,0 +1,522 @@ +# This file is part of cloud-init. See LICENSE file for license information. + +import copy +import errno +import mock +import os + +import cloudinit.net as net +from cloudinit.util import ensure_file, write_file, ProcessExecutionError +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() + sys_mock = mock.patch('cloudinit.net.get_sys_class_path') + self.m_sys_path = sys_mock.start() + self.sysdir = self.tmp_dir() + '/' + self.m_sys_path.return_value = self.sysdir + self.addCleanup(sys_mock.stop) + + 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 a 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_handles_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_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() + sys_mock = mock.patch('cloudinit.net.get_sys_class_path') + self.m_sys_path = sys_mock.start() + self.sysdir = self.tmp_dir() + '/' + self.m_sys_path.return_value = self.sysdir + self.addCleanup(sys_mock.stop) + + 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() + sys_mock = mock.patch('cloudinit.net.get_sys_class_path') + self.m_sys_path = sys_mock.start() + self.sysdir = self.tmp_dir() + '/' + self.m_sys_path.return_value = self.sysdir + self.addCleanup(sys_mock.stop) + + def test_get_devicelist_raise_oserror(self): + """get_devicelist raise any non-ENOENT OSerror.""" + error = OSError('Can not do it') + error.errno = errno.EPERM # Set non-ENOENT + self.m_sys_path.side_effect = error + with self.assertRaises(OSError) as context_manager: + net.get_devicelist() + exception = context_manager.exception + self.assertEqual('Can not do it', str(exception)) + + def test_get_devicelist_empty_without_sys_net(self): + """get_devicelist returns empty list when missing SYS_CLASS_NET.""" + self.m_sys_path.return_value = 'idontexist' + self.assertEqual([], net.get_devicelist()) + + def test_get_devicelist_empty_with_no_devices_in_sys_net(self): + """get_devicelist returns empty directoty listing for SYS_CLASS_NET.""" + self.assertEqual([], net.get_devicelist()) + + def test_get_devicelist_lists_any_subdirectories_in_sys_net(self): + """get_devicelist returns a directory listing for SYS_CLASS_NET.""" + write_file(os.path.join(self.sysdir, 'eth0', 'operstate'), 'up') + write_file(os.path.join(self.sysdir, 'eth1', 'operstate'), 'up') + self.assertItemsEqual(['eth0', 'eth1'], net.get_devicelist()) + + +class TestGetInterfaceMAC(CiTestCase): + + def setUp(self): + super(TestGetInterfaceMAC, self).setUp() + sys_mock = mock.patch('cloudinit.net.get_sys_class_path') + self.m_sys_path = sys_mock.start() + self.sysdir = self.tmp_dir() + '/' + self.m_sys_path.return_value = self.sysdir + self.addCleanup(sys_mock.stop) + + 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.sysdir, '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_empty_list_without_sys_net(self): + """get_interfaces returns an empty list when missing SYS_CLASS_NET.""" + self.m_sys_path.return_value = 'idontexist' + self.assertEqual([], net.get_interfaces()) + + 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() + sys_mock = mock.patch('cloudinit.net.get_sys_class_path') + self.m_sys_path = sys_mock.start() + self.sysdir = self.tmp_dir() + '/' + self.m_sys_path.return_value = self.sysdir + self.addCleanup(sys_mock.stop) + + 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 TestEphemeralIPV4Network(CiTestCase): + + with_logs = True + + def setUp(self): + super(TestEphemeralIPV4Network, self).setUp() + sys_mock = mock.patch('cloudinit.net.get_sys_class_path') + self.m_sys_path = sys_mock.start() + self.sysdir = self.tmp_dir() + '/' + self.m_sys_path.return_value = self.sysdir + self.addCleanup(sys_mock.stop) + + def test_ephemeral_ipv4_network_errors_on_missing_params(self, m_subp): + """No required params for EphemeralIPv4Network can be None.""" + required_params = { + 'interface': 'eth0', 'ip': '192.168.2.2', + 'prefix_or_mask': '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.EphemeralIPv4Network(**params) + error = context_manager.exception + 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): + """Raise an error when prefix_or_mask is not a netmask or prefix.""" + params = { + 'interface': 'eth0', 'ip': '192.168.2.2', + 'broadcast': '192.168.2.255'} + invalid_masks = ('invalid', 'invalid.', '123.123.123') + for error_val in invalid_masks: + params['prefix_or_mask'] = error_val + with self.assertRaises(ValueError) as context_manager: + with net.EphemeralIPv4Network(**params): + pass + error = context_manager.exception + self.assertIn('Cannot setup network: netmask', str(error)) + self.assertEqual(0, m_subp.call_count) + + def test_ephemeral_ipv4_network_performs_teardown(self, m_subp): + """EphemeralIPv4Network performs teardown on the device if setup.""" + expected_setup_calls = [ + mock.call( + ['ip', '-family', 'inet', 'addr', 'add', '192.168.2.2/24', + 'broadcast', '192.168.2.255', 'dev', 'eth0'], + capture=True, update_env={'LANG': 'C'}), + mock.call( + ['ip', '-family', 'inet', 'link', 'set', 'dev', 'eth0', 'up'], + capture=True)] + expected_teardown_calls = [ + mock.call( + ['ip', '-family', 'inet', 'link', 'set', 'dev', 'eth0', + 'down'], capture=True), + mock.call( + ['ip', '-family', 'inet', 'addr', 'del', '192.168.2.2/24', + 'dev', 'eth0'], capture=True)] + params = { + 'interface': 'eth0', 'ip': '192.168.2.2', + 'prefix_or_mask': '255.255.255.0', 'broadcast': '192.168.2.255'} + with net.EphemeralIPv4Network(**params): + self.assertEqual(expected_setup_calls, m_subp.call_args_list) + m_subp.assert_has_calls(expected_teardown_calls) + + def test_ephemeral_ipv4_network_noop_when_configured(self, m_subp): + """EphemeralIPv4Network handles exception when address is setup. + + It performs no cleanup as the interface was already setup. + """ + params = { + 'interface': 'eth0', 'ip': '192.168.2.2', + 'prefix_or_mask': '255.255.255.0', 'broadcast': '192.168.2.255'} + m_subp.side_effect = ProcessExecutionError( + '', 'RTNETLINK answers: File exists', 2) + expected_calls = [ + mock.call( + ['ip', '-family', 'inet', 'addr', 'add', '192.168.2.2/24', + 'broadcast', '192.168.2.255', 'dev', 'eth0'], + capture=True, update_env={'LANG': 'C'})] + with net.EphemeralIPv4Network(**params): + pass + self.assertEqual(expected_calls, m_subp.call_args_list) + self.assertIn( + 'Skip ephemeral network setup, eth0 already has address', + self.logs.getvalue()) + + def test_ephemeral_ipv4_network_with_prefix(self, m_subp): + """EphemeralIPv4Network takes a valid prefix to setup the network.""" + params = { + 'interface': 'eth0', 'ip': '192.168.2.2', + 'prefix_or_mask': '24', 'broadcast': '192.168.2.255'} + for prefix_val in ['24', 16]: # prefix can be int or string + params['prefix_or_mask'] = prefix_val + with net.EphemeralIPv4Network(**params): + pass + 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, update_env={'LANG': 'C'})]) + m_subp.assert_has_calls([mock.call( + ['ip', '-family', 'inet', 'addr', 'add', '192.168.2.2/16', + 'broadcast', '192.168.2.255', 'dev', 'eth0'], + capture=True, update_env={'LANG': 'C'})]) + + def test_ephemeral_ipv4_network_with_new_default_route(self, m_subp): + """Add the route when router is set and no default route exists.""" + params = { + 'interface': 'eth0', 'ip': '192.168.2.2', + 'prefix_or_mask': '255.255.255.0', 'broadcast': '192.168.2.255', + 'router': '192.168.2.1'} + m_subp.return_value = '', '' # Empty response from ip route gw check + expected_setup_calls = [ + mock.call( + ['ip', '-family', 'inet', 'addr', 'add', '192.168.2.2/24', + 'broadcast', '192.168.2.255', 'dev', 'eth0'], + capture=True, update_env={'LANG': 'C'}), + mock.call( + ['ip', '-family', 'inet', 'link', 'set', 'dev', 'eth0', 'up'], + capture=True), + mock.call( + ['ip', 'route', 'show', '0.0.0.0/0'], capture=True), + mock.call( + ['ip', '-4', 'route', 'add', 'default', 'via', + '192.168.2.1', 'dev', 'eth0'], capture=True)] + expected_teardown_calls = [mock.call( + ['ip', '-4', 'route', 'del', 'default', 'dev', 'eth0'], + capture=True)] + + with net.EphemeralIPv4Network(**params): + self.assertEqual(expected_setup_calls, m_subp.call_args_list) + m_subp.assert_has_calls(expected_teardown_calls) diff --git a/cloudinit/net/udev.py b/cloudinit/net/udev.py index fd2fd8c7..58c0a708 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), ]) |