diff options
-rw-r--r-- | cloudinit/distros/debian.py | 12 | ||||
-rw-r--r-- | cloudinit/distros/rhel.py | 8 | ||||
-rw-r--r-- | cloudinit/net/__init__.py | 1 | ||||
-rw-r--r-- | cloudinit/net/eni.py | 69 | ||||
-rw-r--r-- | cloudinit/net/network_state.py | 104 | ||||
-rw-r--r-- | cloudinit/net/renderer.py | 48 | ||||
-rw-r--r-- | cloudinit/net/sysconfig.py | 400 | ||||
-rw-r--r-- | tests/unittests/test_net.py | 182 |
8 files changed, 722 insertions, 102 deletions
diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py index 603b0b61..53f3aa4d 100644 --- a/cloudinit/distros/debian.py +++ b/cloudinit/distros/debian.py @@ -57,7 +57,11 @@ class Distro(distros.Distro): # should only happen say once per instance...) self._runner = helpers.Runners(paths) self.osfamily = 'debian' - self._net_renderer = eni.Renderer() + self._net_renderer = eni.Renderer({ + 'eni_path': self.network_conf_fn, + 'links_prefix_path': self.links_prefix, + 'netrules_path': None, + }) def apply_locale(self, locale, out_fn=None): if not out_fn: @@ -82,12 +86,8 @@ class Distro(distros.Distro): def _write_network_config(self, netconfig): ns = parse_net_config_data(netconfig) - self._net_renderer.render_network_state( - target="/", network_state=ns, - eni=self.network_conf_fn, links_prefix=self.links_prefix, - netrules=None) + self._net_renderer.render_network_state("/", ns) _maybe_remove_legacy_eth0() - return [] def _bring_up_interfaces(self, device_names): diff --git a/cloudinit/distros/rhel.py b/cloudinit/distros/rhel.py index 812e7002..1aa42d75 100644 --- a/cloudinit/distros/rhel.py +++ b/cloudinit/distros/rhel.py @@ -23,6 +23,8 @@ from cloudinit import distros from cloudinit import helpers from cloudinit import log as logging +from cloudinit.net.network_state import parse_net_config_data +from cloudinit.net import sysconfig from cloudinit import util from cloudinit.distros import net_util @@ -59,10 +61,16 @@ class Distro(distros.Distro): # should only happen say once per instance...) self._runner = helpers.Runners(paths) self.osfamily = 'redhat' + self._net_renderer = sysconfig.Renderer() def install_packages(self, pkglist): self.package_command('install', pkgs=pkglist) + def _write_network_config(self, netconfig): + ns = parse_net_config_data(netconfig) + self._net_renderer.render_network_state("/", ns) + return [] + def _write_network(self, settings): # TODO(harlowja) fix this... since this is the ubuntu format entries = net_util.translate_network(settings) diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py index f5668fff..6959ad34 100644 --- a/cloudinit/net/__init__.py +++ b/cloudinit/net/__init__.py @@ -26,7 +26,6 @@ from cloudinit import util LOG = logging.getLogger(__name__) SYS_CLASS_NET = "/sys/class/net/" DEFAULT_PRIMARY_INTERFACE = 'eth0' -LINKS_FNAME_PREFIX = "etc/systemd/network/50-cloud-init-" def sys_dev_path(devname, path=""): diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py index 5a90eb32..ccd16ba7 100644 --- a/cloudinit/net/eni.py +++ b/cloudinit/net/eni.py @@ -16,10 +16,9 @@ import glob import os import re -from . import LINKS_FNAME_PREFIX from . import ParserError -from .udev import generate_udev_rule +from . import renderer from cloudinit import util @@ -297,21 +296,17 @@ def _ifaces_to_net_config_data(ifaces): 'config': [devs[d] for d in sorted(devs)]} -class Renderer(object): +class Renderer(renderer.Renderer): """Renders network information in a /etc/network/interfaces format.""" - def _render_persistent_net(self, network_state): - """Given state, emit udev rules to map mac to ifname.""" - content = "" - interfaces = network_state.get('interfaces') - for iface in interfaces.values(): - # for physical interfaces write out a persist net udev rule - if iface['type'] == 'physical' and \ - 'name' in iface and iface.get('mac_address'): - content += generate_udev_rule(iface['name'], - iface['mac_address']) - - return content + def __init__(self, config=None): + if not config: + config = {} + self.eni_path = config.get('eni_path', 'etc/network/interfaces') + 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') def _render_route(self, route, indent=""): """When rendering routes for an iface, in some cases applying a route @@ -360,7 +355,15 @@ class Renderer(object): '''Given state, emit etc/network/interfaces content.''' content = "" - interfaces = network_state.get('interfaces') + content += "auto lo\niface lo inet loopback\n" + + nameservers = network_state.dns_nameservers + if nameservers: + content += " dns-nameservers %s\n" % (" ".join(nameservers)) + searchdomains = network_state.dns_searchdomains + if searchdomains: + content += " dns-search %s\n" % (" ".join(searchdomains)) + ''' Apply a sort order to ensure that we write out the physical interfaces first; this is critical for bonding @@ -371,12 +374,7 @@ class Renderer(object): 'bridge': 2, 'vlan': 3, } - content += "auto lo\niface lo inet loopback\n" - for dnskey, value in network_state.get('dns', {}).items(): - if len(value): - content += " dns-{} {}\n".format(dnskey, " ".join(value)) - - for iface in sorted(interfaces.values(), + for iface in sorted(network_state.iter_interfaces(), key=lambda k: (order[k['type']], k['name'])): if content[-2:] != "\n\n": @@ -409,40 +407,33 @@ class Renderer(object): content += "iface {name} {inet} {mode}\n".format(**iface) content += _iface_add_attrs(iface) - for route in network_state.get('routes'): + for route in network_state.iter_routes(): content += self._render_route(route) # global replacements until v2 format content = content.replace('mac_address', 'hwaddress') return content - def render_network_state( - self, target, network_state, eni="etc/network/interfaces", - links_prefix=LINKS_FNAME_PREFIX, - netrules='etc/udev/rules.d/70-persistent-net.rules', - writer=None): - - fpeni = os.path.sep.join((target, eni,)) + def render_network_state(self, target, network_state): + fpeni = os.path.join(target, self.eni_path) util.ensure_dir(os.path.dirname(fpeni)) util.write_file(fpeni, self._render_interfaces(network_state)) - if netrules: - netrules = os.path.sep.join((target, netrules,)) + if self.netrules_path: + netrules = os.path.join(target, self.netrules_path) util.ensure_dir(os.path.dirname(netrules)) util.write_file(netrules, self._render_persistent_net(network_state)) - if links_prefix: + if self.links_path_prefix: self._render_systemd_links(target, network_state, - links_prefix=links_prefix) + links_prefix=self.links_path_prefix) - def _render_systemd_links(self, target, network_state, - links_prefix=LINKS_FNAME_PREFIX): - fp_prefix = os.path.sep.join((target, links_prefix)) + def _render_systemd_links(self, target, network_state, links_prefix): + fp_prefix = os.path.join(target, links_prefix) for f in glob.glob(fp_prefix + "*"): os.unlink(f) - interfaces = network_state.get('interfaces') - for iface in interfaces.values(): + 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" diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py index a8be5e26..8ca5106f 100644 --- a/cloudinit/net/network_state.py +++ b/cloudinit/net/network_state.py @@ -38,10 +38,10 @@ def parse_net_config_data(net_config, skip_broken=True): """ state = None if 'version' in net_config and 'config' in net_config: - ns = NetworkState(version=net_config.get('version'), - config=net_config.get('config')) - ns.parse_config(skip_broken=skip_broken) - state = ns.network_state + nsi = NetworkStateInterpreter(version=net_config.get('version'), + config=net_config.get('config')) + nsi.parse_config(skip_broken=skip_broken) + state = nsi.network_state return state @@ -57,11 +57,10 @@ def parse_net_config(path, skip_broken=True): def from_state_file(state_file): - network_state = None state = util.read_conf(state_file) - network_state = NetworkState() - network_state.load(state) - return network_state + nsi = NetworkStateInterpreter() + nsi.load(state) + return nsi def diff_keys(expected, actual): @@ -113,9 +112,51 @@ class CommandHandlerMeta(type): parents, dct) -@six.add_metaclass(CommandHandlerMeta) class NetworkState(object): + def __init__(self, network_state, version=NETWORK_STATE_VERSION): + self._network_state = copy.deepcopy(network_state) + self._version = version + + @property + def version(self): + return self._version + + def iter_routes(self, filter_func=None): + for route in self._network_state.get('routes', []): + if filter_func is not None: + if filter_func(route): + yield route + else: + yield route + + @property + def dns_nameservers(self): + try: + return self._network_state['dns']['nameservers'] + except KeyError: + return [] + + @property + def dns_searchdomains(self): + try: + return self._network_state['dns']['search'] + except KeyError: + return [] + + def iter_interfaces(self, filter_func=None): + ifaces = self._network_state.get('interfaces', {}) + for iface in six.itervalues(ifaces): + if filter_func is None: + yield iface + else: + if filter_func(iface): + yield iface + + +@six.add_metaclass(CommandHandlerMeta) +class NetworkStateInterpreter(object): + initial_network_state = { 'interfaces': {}, 'routes': [], @@ -126,22 +167,27 @@ class NetworkState(object): } 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._version = version + self._config = config + self._network_state = copy.deepcopy(self.initial_network_state) + self._parsed = False + + @property + def network_state(self): + return NetworkState(self._network_state, version=self._version) def dump(self): state = { - 'version': self.version, - 'config': self.config, - 'network_state': self.network_state, + 'version': self._version, + 'config': self._config, + 'network_state': self._network_state, } return util.yaml_dumps(state) def load(self, state): if 'version' not in state: LOG.error('Invalid state, missing version field') - raise Exception('Invalid state, missing version field') + raise ValueError('Invalid state, missing version field') required_keys = NETWORK_STATE_REQUIRED_KEYS[state['version']] missing_keys = diff_keys(required_keys, state) @@ -155,11 +201,11 @@ class NetworkState(object): setattr(self, key, state[key]) def dump_network_state(self): - return util.yaml_dumps(self.network_state) + return util.yaml_dumps(self._network_state) def parse_config(self, skip_broken=True): # rebuild network state - for command in self.config: + for command in self._config: command_type = command['type'] try: handler = self.command_handlers[command_type] @@ -189,7 +235,7 @@ class NetworkState(object): } ''' - interfaces = self.network_state.get('interfaces') + interfaces = self._network_state.get('interfaces', {}) iface = interfaces.get(command['name'], {}) for param, val in command.get('params', {}).items(): iface.update({param: val}) @@ -215,7 +261,7 @@ class NetworkState(object): 'gateway': None, 'subnets': subnets, }) - self.network_state['interfaces'].update({command.get('name'): iface}) + self._network_state['interfaces'].update({command.get('name'): iface}) self.dump_network_state() @ensure_command_keys(['name', 'vlan_id', 'vlan_link']) @@ -228,7 +274,7 @@ class NetworkState(object): hwaddress ether BC:76:4E:06:96:B3 vlan-raw-device eth0 ''' - interfaces = self.network_state.get('interfaces') + interfaces = self._network_state.get('interfaces', {}) self.handle_physical(command) iface = interfaces.get(command.get('name'), {}) iface['vlan-raw-device'] = command.get('vlan_link') @@ -263,12 +309,12 @@ class NetworkState(object): ''' self.handle_physical(command) - interfaces = self.network_state.get('interfaces') + interfaces = self._network_state.get('interfaces') iface = interfaces.get(command.get('name'), {}) for param, val in command.get('params').items(): iface.update({param: val}) iface.update({'bond-slaves': 'none'}) - self.network_state['interfaces'].update({iface['name']: iface}) + self._network_state['interfaces'].update({iface['name']: iface}) # handle bond slaves for ifname in command.get('bond_interfaces'): @@ -280,13 +326,13 @@ class NetworkState(object): # inject placeholder self.handle_physical(cmd) - interfaces = self.network_state.get('interfaces') + interfaces = self._network_state.get('interfaces', {}) bond_if = interfaces.get(ifname) bond_if['bond-master'] = command.get('name') # copy in bond config into slave for param, val in command.get('params').items(): bond_if.update({param: val}) - self.network_state['interfaces'].update({ifname: bond_if}) + self._network_state['interfaces'].update({ifname: bond_if}) @ensure_command_keys(['name', 'bridge_interfaces', 'params']) def handle_bridge(self, command): @@ -319,7 +365,7 @@ class NetworkState(object): # find one of the bridge port ifaces to get mac_addr # handle bridge_slaves - interfaces = self.network_state.get('interfaces') + interfaces = self._network_state.get('interfaces', {}) for ifname in command.get('bridge_interfaces'): if ifname in interfaces: continue @@ -330,7 +376,7 @@ class NetworkState(object): # inject placeholder self.handle_physical(cmd) - interfaces = self.network_state.get('interfaces') + interfaces = self._network_state.get('interfaces', {}) self.handle_physical(command) iface = interfaces.get(command.get('name'), {}) iface['bridge_ports'] = command['bridge_interfaces'] @@ -341,7 +387,7 @@ class NetworkState(object): @ensure_command_keys(['address']) def handle_nameserver(self, command): - dns = self.network_state.get('dns') + dns = self._network_state.get('dns') if 'address' in command: addrs = command['address'] if not type(addrs) == list: @@ -357,7 +403,7 @@ class NetworkState(object): @ensure_command_keys(['destination']) def handle_route(self, command): - routes = self.network_state.get('routes') + routes = self._network_state.get('routes', []) network, cidr = command['destination'].split("/") netmask = cidr2mask(int(cidr)) route = { diff --git a/cloudinit/net/renderer.py b/cloudinit/net/renderer.py new file mode 100644 index 00000000..310cbe0d --- /dev/null +++ b/cloudinit/net/renderer.py @@ -0,0 +1,48 @@ +# Copyright (C) 2013-2014 Canonical Ltd. +# +# Author: Scott Moser <scott.moser@canonical.com> +# Author: Blake Rouse <blake.rouse@canonical.com> +# +# Curtin is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# Curtin is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Curtin. If not, see <http://www.gnu.org/licenses/>. + +import six + +from .udev import generate_udev_rule + + +def filter_by_type(match_type): + return lambda iface: match_type == iface['type'] + + +def filter_by_name(match_name): + return lambda iface: match_name == iface['name'] + + +filter_by_physical = filter_by_type('physical') + + +class Renderer(object): + + @staticmethod + def _render_persistent_net(network_state): + """Given state, emit udev rules to map mac to ifname.""" + # TODO(harlowja): this seems shared between eni renderer and + # this, so move it to a shared location. + content = six.StringIO() + 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'): + content.write(generate_udev_rule(iface['name'], + iface['mac_address'])) + return content.getvalue() diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py new file mode 100644 index 00000000..c53acf71 --- /dev/null +++ b/cloudinit/net/sysconfig.py @@ -0,0 +1,400 @@ +# vi: ts=4 expandtab +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import re + +import six + +from cloudinit.distros.parsers import resolv_conf +from cloudinit import util + +from . import renderer + + +def _make_header(sep='#'): + lines = [ + "Created by cloud-init on instance boot automatically, do not edit.", + "", + ] + for i in range(0, len(lines)): + if lines[i]: + lines[i] = sep + " " + lines[i] + else: + lines[i] = sep + return "\n".join(lines) + + +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 + + +def _quote_value(value): + if re.search(r"\s", value): + # This doesn't handle complex cases... + if value.startswith('"') and value.endswith('"'): + return value + else: + return '"%s"' % value + else: + return value + + +class ConfigMap(object): + """Sysconfig like dictionary object.""" + + # Why does redhat prefer yes/no to true/false?? + _bool_map = { + True: 'yes', + False: 'no', + } + + def __init__(self): + self._conf = {} + + def __setitem__(self, key, value): + self._conf[key] = value + + def drop(self, key): + self._conf.pop(key, None) + + def __len__(self): + return len(self._conf) + + def to_string(self): + buf = six.StringIO() + buf.write(_make_header()) + if self._conf: + buf.write("\n") + for key in sorted(self._conf.keys()): + value = self._conf[key] + if isinstance(value, bool): + value = self._bool_map[value] + if not isinstance(value, six.string_types): + value = str(value) + buf.write("%s=%s\n" % (key, _quote_value(value))) + return buf.getvalue() + + +class Route(ConfigMap): + """Represents a route configuration.""" + + route_fn_tpl = '%(base)s/network-scripts/route-%(name)s' + + def __init__(self, route_name, base_sysconf_dir): + super(Route, self).__init__() + self.last_idx = 1 + self.has_set_default = False + self._route_name = route_name + self._base_sysconf_dir = base_sysconf_dir + + def copy(self): + r = Route(self._route_name, self._base_sysconf_dir) + r._conf = self._conf.copy() + r.last_idx = self.last_idx + r.has_set_default = self.has_set_default + return r + + @property + def path(self): + return self.route_fn_tpl % ({'base': self._base_sysconf_dir, + 'name': self._route_name}) + + +class NetInterface(ConfigMap): + """Represents a sysconfig/networking-script (and its config + children).""" + + iface_fn_tpl = '%(base)s/network-scripts/ifcfg-%(name)s' + + iface_types = { + 'ethernet': 'Ethernet', + 'bond': 'Bond', + 'bridge': 'Bridge', + } + + def __init__(self, iface_name, base_sysconf_dir, kind='ethernet'): + super(NetInterface, self).__init__() + self.children = [] + self.routes = Route(iface_name, base_sysconf_dir) + self._kind = kind + self._iface_name = iface_name + self._conf['DEVICE'] = iface_name + self._conf['TYPE'] = self.iface_types[kind] + self._base_sysconf_dir = base_sysconf_dir + + @property + def name(self): + return self._iface_name + + @name.setter + def name(self, iface_name): + self._iface_name = iface_name + self._conf['DEVICE'] = iface_name + + @property + def kind(self): + return self._kind + + @kind.setter + def kind(self, kind): + self._kind = kind + self._conf['TYPE'] = self.iface_types[kind] + + @property + def path(self): + return self.iface_fn_tpl % ({'base': self._base_sysconf_dir, + 'name': self.name}) + + def copy(self, copy_children=False, copy_routes=False): + c = NetInterface(self.name, self._base_sysconf_dir, kind=self._kind) + c._conf = self._conf.copy() + if copy_children: + c.children = list(self.children) + if copy_routes: + c.routes = self.routes.copy() + return c + + +class Renderer(renderer.Renderer): + """Renders network information in a /etc/sysconfig format.""" + + # See: https://access.redhat.com/documentation/en-US/\ + # Red_Hat_Enterprise_Linux/6/html/Deployment_Guide/\ + # s1-networkscripts-interfaces.html (or other docs for + # details about this) + + iface_defaults = tuple([ + ('ONBOOT', True), + ('USERCTL', False), + ('NM_CONTROLLED', False), + ('BOOTPROTO', 'none'), + ]) + + # If these keys exist, then there values will be used to form + # a BONDING_OPTS grouping; otherwise no grouping will be set. + bond_tpl_opts = tuple([ + ('bond_mode', "mode=%s"), + ('bond_xmit_hash_policy', "xmit_hash_policy=%s"), + ('bond_miimon', "miimon=%s"), + ]) + + bridge_opts_keys = tuple([ + ('bridge_stp', 'STP'), + ('bridge_ageing', 'AGEING'), + ('bridge_bridgeprio', 'PRIO'), + ]) + + def __init__(self, config=None): + if not config: + config = {} + self.sysconf_dir = config.get('sysconf_dir', 'etc/sysconfig/') + 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') + + @classmethod + def _render_iface_shared(cls, iface, iface_cfg): + for k, v in cls.iface_defaults: + iface_cfg[k] = v + for (old_key, new_key) in [('mac_address', 'HWADDR'), ('mtu', 'MTU')]: + old_value = iface.get(old_key) + if old_value is not None: + iface_cfg[new_key] = old_value + + @classmethod + def _render_subnet(cls, iface_cfg, route_cfg, subnet): + subnet_type = subnet.get('type') + if subnet_type == 'dhcp6': + iface_cfg['DHCPV6C'] = True + iface_cfg['IPV6INIT'] = True + iface_cfg['BOOTPROTO'] = 'dhcp' + elif subnet_type in ['dhcp4', 'dhcp']: + iface_cfg['BOOTPROTO'] = 'dhcp' + elif subnet_type == 'static': + iface_cfg['BOOTPROTO'] = 'static' + if subnet.get('ipv6'): + iface_cfg['IPV6ADDR'] = subnet['address'] + iface_cfg['IPV6INIT'] = True + else: + iface_cfg['IPADDR'] = subnet['address'] + else: + raise ValueError("Unknown subnet type '%s' found" + " for interface '%s'" % (subnet_type, + iface_cfg.name)) + if 'netmask' in subnet: + iface_cfg['NETMASK'] = subnet['netmask'] + for route in subnet.get('routes', []): + if _is_default_route(route): + if route_cfg.has_set_default: + raise ValueError("Duplicate declaration of default" + " route found for interface '%s'" + % (iface_cfg.name)) + # NOTE(harlowja): ipv6 and ipv4 default gateways + gw_key = 'GATEWAY0' + nm_key = 'NETMASK0' + addr_key = 'ADDRESS0' + # The owning interface provides the default route. + # + # TODO(harlowja): add validation that no other iface has + # also provided the default route? + iface_cfg['DEFROUTE'] = True + if 'gateway' in route: + iface_cfg['GATEWAY'] = route['gateway'] + route_cfg.has_set_default = True + else: + gw_key = 'GATEWAY%s' % route_cfg.last_idx + 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] + + @classmethod + def _render_bonding_opts(cls, iface_cfg, iface): + bond_opts = [] + for (bond_key, value_tpl) in cls.bond_tpl_opts: + # Seems like either dash or underscore is possible? + bond_keys = [bond_key, bond_key.replace("_", "-")] + for bond_key in bond_keys: + if bond_key in iface: + bond_value = iface[bond_key] + if isinstance(bond_value, (tuple, list)): + bond_value = " ".join(bond_value) + bond_opts.append(value_tpl % (bond_value)) + break + if bond_opts: + iface_cfg['BONDING_OPTS'] = " ".join(bond_opts) + + @classmethod + def _render_physical_interfaces(cls, network_state, iface_contents): + physical_filter = renderer.filter_by_physical + for iface in network_state.iter_interfaces(physical_filter): + iface_name = iface['name'] + iface_subnets = iface.get("subnets", []) + iface_cfg = iface_contents[iface_name] + route_cfg = iface_cfg.routes + if len(iface_subnets) == 1: + cls._render_subnet(iface_cfg, route_cfg, iface_subnets[0]) + elif len(iface_subnets) > 1: + for i, iface_subnet in enumerate(iface_subnets, + start=len(iface.children)): + iface_sub_cfg = iface_cfg.copy() + iface_sub_cfg.name = "%s:%s" % (iface_name, i) + iface.children.append(iface_sub_cfg) + cls._render_subnet(iface_sub_cfg, route_cfg, iface_subnet) + + @classmethod + def _render_bond_interfaces(cls, network_state, iface_contents): + bond_filter = renderer.filter_by_type('bond') + 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) + for master_cfg in master_cfgs: + master_cfg['BONDING_MASTER'] = True + master_cfg.kind = 'bond' + + @staticmethod + def _render_vlan_interfaces(network_state, iface_contents): + vlan_filter = renderer.filter_by_type('vlan') + for iface in network_state.iter_interfaces(vlan_filter): + iface_name = iface['name'] + iface_cfg = iface_contents[iface_name] + iface_cfg['VLAN'] = True + iface_cfg['PHYSDEV'] = iface_name[:iface_name.rfind('.')] + + @staticmethod + def _render_dns(network_state, existing_dns_path=None): + content = resolv_conf.ResolvConf("") + if existing_dns_path and os.path.isfile(existing_dns_path): + content = resolv_conf.ResolvConf(util.load_file(existing_dns_path)) + for nameserver in network_state.dns_nameservers: + content.add_nameserver(nameserver) + for searchdomain in network_state.dns_searchdomains: + content.add_search_domain(searchdomain) + return "\n".join([_make_header(';'), str(content)]) + + @classmethod + def _render_bridge_interfaces(cls, network_state, iface_contents): + bridge_filter = renderer.filter_by_type('bridge') + for iface in network_state.iter_interfaces(bridge_filter): + iface_name = iface['name'] + iface_cfg = iface_contents[iface_name] + iface_cfg.kind = 'bridge' + for old_key, new_key in cls.bridge_opts_keys: + if old_key in iface: + iface_cfg[new_key] = iface[old_key] + # 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 + # as being bridged to this interface. + bridged_cfg = iface_contents[bridged_iface_name] + bridged_cfgs = [bridged_cfg] + bridged_cfgs.extend(bridged_cfg.children) + for bridge_cfg in bridged_cfgs: + bridge_cfg['BRIDGE'] = iface_name + + @classmethod + def _render_sysconfig(cls, base_sysconf_dir, network_state): + '''Given state, return /etc/sysconfig files + contents''' + iface_contents = {} + for iface in network_state.iter_interfaces(): + iface_name = iface['name'] + iface_cfg = NetInterface(iface_name, base_sysconf_dir) + cls._render_iface_shared(iface, iface_cfg) + iface_contents[iface_name] = iface_cfg + cls._render_physical_interfaces(network_state, iface_contents) + cls._render_bond_interfaces(network_state, iface_contents) + cls._render_vlan_interfaces(network_state, iface_contents) + cls._render_bridge_interfaces(network_state, iface_contents) + contents = {} + for iface_name, iface_cfg in iface_contents.items(): + if iface_cfg or iface_cfg.children: + contents[iface_cfg.path] = iface_cfg.to_string() + for iface_cfg in iface_cfg.children: + if iface_cfg: + contents[iface_cfg.path] = iface_cfg.to_string() + if iface_cfg.routes: + contents[iface_cfg.routes.path] = iface_cfg.routes.to_string() + return contents + + def render_network_state(self, target, network_state): + base_sysconf_dir = os.path.join(target, self.sysconf_dir) + for path, data in self._render_sysconfig(base_sysconf_dir, + network_state).items(): + util.write_file(path, data) + if self.dns_path: + dns_path = os.path.join(target, self.dns_path) + resolv_content = self._render_dns(network_state, + existing_dns_path=dns_path) + util.write_file(dns_path, resolv_content) + if self.netrules_path: + netrules_content = self._render_persistent_net(network_state) + netrules_path = os.path.join(target, self.netrules_path) + util.write_file(netrules_path, netrules_content) diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py index 7998111a..3ae00fc6 100644 --- a/tests/unittests/test_net.py +++ b/tests/unittests/test_net.py @@ -2,6 +2,8 @@ from cloudinit import net from cloudinit.net import cmdline from cloudinit.net import eni from cloudinit.net import network_state +from cloudinit.net import sysconfig +from cloudinit.sources.helpers import openstack from cloudinit import util from .helpers import mock @@ -74,8 +76,102 @@ STATIC_EXPECTED_1 = { 'dns_nameservers': ['10.0.1.1']}], } +# Examples (and expected outputs for various renderers). +OS_SAMPLES = [ + { + 'in_data': { + "services": [{"type": "dns", "address": "172.19.0.12"}], + "networks": [{ + "network_id": "dacd568d-5be6-4786-91fe-750c374b78b4", + "type": "ipv4", "netmask": "255.255.252.0", + "link": "tap1a81968a-79", + "routes": [{ + "netmask": "0.0.0.0", + "network": "0.0.0.0", + "gateway": "172.19.3.254", + }], + "ip_address": "172.19.1.34", "id": "network0" + }], + "links": [ + { + "ethernet_mac_address": "fa:16:3e:ed:9a:59", + "mtu": None, "type": "bridge", "id": + "tap1a81968a-79", + "vif_id": "1a81968a-797a-400f-8a80-567f997eb93f" + }, + ], + }, + 'in_macs': { + 'fa:16:3e:ed:9a:59': 'eth0', + }, + 'out_sysconfig': [ + ('etc/sysconfig/network-scripts/ifcfg-eth0', + """ +# Created by cloud-init on instance boot automatically, do not edit. +# +BOOTPROTO=static +DEFROUTE=yes +DEVICE=eth0 +GATEWAY=172.19.3.254 +HWADDR=fa:16:3e:ed:9a:59 +IPADDR=172.19.1.34 +NETMASK=255.255.252.0 +NM_CONTROLLED=no +ONBOOT=yes +TYPE=Ethernet +USERCTL=no +""".lstrip()), + ('etc/sysconfig/network-scripts/route-eth0', + """ +# Created by cloud-init on instance boot automatically, do not edit. +# +ADDRESS0=0.0.0.0 +GATEWAY0=172.19.3.254 +NETMASK0=0.0.0.0 +""".lstrip()), + ('etc/resolv.conf', + """ +; Created by cloud-init on instance boot automatically, do not edit. +; +nameserver 172.19.0.12 +""".lstrip()), + ('etc/udev/rules.d/70-persistent-net.rules', + "".join(['SUBSYSTEM=="net", ACTION=="add", DRIVERS=="?*", ', + 'ATTR{address}=="fa:16:3e:ed:9a:59", NAME="eth0"\n']))] + } +] + + +def _setup_test(tmp_dir, mock_get_devicelist, mock_sys_netdev_info, + mock_sys_dev_path): + mock_get_devicelist.return_value = ['eth1000'] + dev_characteristics = { + 'eth1000': { + "bridge": False, + "carrier": False, + "dormant": False, + "operstate": "down", + "address": "07-1C-C6-75-A4-BE", + } + } + + def netdev_info(name, field): + return dev_characteristics[name][field] + + mock_sys_netdev_info.side_effect = netdev_info + + def sys_dev_path(devname, path=""): + return tmp_dir + devname + "/" + path + + for dev in dev_characteristics: + os.makedirs(os.path.join(tmp_dir, dev)) + with open(os.path.join(tmp_dir, dev, 'operstate'), 'w') as fh: + fh.write("down") + + mock_sys_dev_path.side_effect = sys_dev_path -class TestEniNetRendering(TestCase): + +class TestSysConfigRendering(TestCase): @mock.patch("cloudinit.net.sys_dev_path") @mock.patch("cloudinit.net.sys_netdev_info") @@ -83,35 +179,67 @@ class TestEniNetRendering(TestCase): def test_default_generation(self, mock_get_devicelist, mock_sys_netdev_info, mock_sys_dev_path): - mock_get_devicelist.return_value = ['eth1000', 'lo'] - - dev_characteristics = { - 'eth1000': { - "bridge": False, - "carrier": False, - "dormant": False, - "operstate": "down", - "address": "07-1C-C6-75-A4-BE", - } - } + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir) + _setup_test(tmp_dir, mock_get_devicelist, + mock_sys_netdev_info, mock_sys_dev_path) - def netdev_info(name, field): - return dev_characteristics[name][field] + network_cfg = net.generate_fallback_config() + ns = network_state.parse_net_config_data(network_cfg, + skip_broken=False) - mock_sys_netdev_info.side_effect = netdev_info + render_dir = os.path.join(tmp_dir, "render") + os.makedirs(render_dir) + renderer = sysconfig.Renderer() + renderer.render_network_state(render_dir, ns) + + render_file = 'etc/sysconfig/network-scripts/ifcfg-eth1000' + with open(os.path.join(render_dir, render_file)) as fh: + content = fh.read() + expected_content = """ +# Created by cloud-init on instance boot automatically, do not edit. +# +BOOTPROTO=dhcp +DEVICE=eth1000 +HWADDR=07-1C-C6-75-A4-BE +NM_CONTROLLED=no +ONBOOT=yes +TYPE=Ethernet +USERCTL=no +""".lstrip() + self.assertEqual(expected_content, content) + + def test_openstack_rendering_samples(self): tmp_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, tmp_dir) + render_dir = os.path.join(tmp_dir, "render") + for os_sample in OS_SAMPLES: + ex_input = os_sample['in_data'] + ex_mac_addrs = os_sample['in_macs'] + network_cfg = openstack.convert_net_json( + ex_input, known_macs=ex_mac_addrs) + ns = network_state.parse_net_config_data(network_cfg, + skip_broken=False) + renderer = sysconfig.Renderer() + renderer.render_network_state(render_dir, ns) + for fn, expected_content in os_sample.get('out_sysconfig', []): + with open(os.path.join(render_dir, fn)) as fh: + self.assertEqual(expected_content, fh.read()) - def sys_dev_path(devname, path=""): - return tmp_dir + devname + "/" + path - for dev in dev_characteristics: - os.makedirs(os.path.join(tmp_dir, dev)) - with open(os.path.join(tmp_dir, dev, 'operstate'), 'w') as fh: - fh.write("down") +class TestEniNetRendering(TestCase): - mock_sys_dev_path.side_effect = sys_dev_path + @mock.patch("cloudinit.net.sys_dev_path") + @mock.patch("cloudinit.net.sys_netdev_info") + @mock.patch("cloudinit.net.get_devicelist") + def test_default_generation(self, mock_get_devicelist, + mock_sys_netdev_info, + mock_sys_dev_path): + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir) + _setup_test(tmp_dir, mock_get_devicelist, + mock_sys_netdev_info, mock_sys_dev_path) network_cfg = net.generate_fallback_config() ns = network_state.parse_net_config_data(network_cfg, @@ -120,11 +248,11 @@ class TestEniNetRendering(TestCase): render_dir = os.path.join(tmp_dir, "render") os.makedirs(render_dir) - renderer = eni.Renderer() - renderer.render_network_state(render_dir, ns, - eni="interfaces", - links_prefix=None, - netrules=None) + renderer = eni.Renderer( + {'links_path_prefix': None, + 'eni_path': 'interfaces', 'netrules_path': None, + }) + renderer.render_network_state(render_dir, ns) self.assertTrue(os.path.exists(os.path.join(render_dir, 'interfaces'))) |