← Back to team overview

cloud-init-dev team mailing list archive

[Merge] ~raharper/cloud-init:netconfig-v2-passthrough into cloud-init:master

 

Ryan Harper has proposed merging ~raharper/cloud-init:netconfig-v2-passthrough into cloud-init:master.

Requested reviews:
  cloud init development team (cloud-init-dev)

For more details, see:
https://code.launchpad.net/~raharper/cloud-init/+git/cloud-init/+merge/319259

cloudinit.net: add v2 parsing, and v2 rendering

Network configuration version2 format is implemented in a package
called netplan (nplan)[1] which allows consolidated network config
for multiple network controllers.

- Detect and determine if Ubuntu Distro object will render eni or netplan
- Allow v2 configs to be passed directly to netplan
- Allow any network state (parsed from any format cloud-init supports) to
  render to v2 if system supports netplan.

1. https://lists.ubuntu.com/archives/ubuntu-devel/2016-July/039464.html
-- 
Your team cloud init development team is requested to review the proposed merge of ~raharper/cloud-init:netconfig-v2-passthrough into cloud-init:master.
diff --git a/cloudinit/distros/debian.py b/cloudinit/distros/debian.py
index 48ccec8..f77b1e3 100644
--- a/cloudinit/distros/debian.py
+++ b/cloudinit/distros/debian.py
@@ -14,6 +14,7 @@ from cloudinit import distros
 from cloudinit import helpers
 from cloudinit import log as logging
 from cloudinit.net import eni
+from cloudinit.net import netplan
 from cloudinit.net.network_state import parse_net_config_data
 from cloudinit import util
 
@@ -38,11 +39,16 @@ ENI_HEADER = """# This file is generated from information provided by
 # network: {config: disabled}
 """
 
+NETPLAN_GENERATE = ['netplan', 'generate']
+
 
 class Distro(distros.Distro):
     hostname_conf_fn = "/etc/hostname"
     locale_conf_fn = "/etc/default/locale"
-    network_conf_fn = "/etc/network/interfaces.d/50-cloud-init.cfg"
+    network_conf_fn = {
+        1: "/etc/network/interfaces.d/50-cloud-init.cfg",
+        2: "/etc/netplan/50-cloud-init.yaml"
+    }
 
     def __init__(self, name, cfg, paths):
         distros.Distro.__init__(self, name, cfg, paths)
@@ -51,12 +57,7 @@ class Distro(distros.Distro):
         # should only happen say once per instance...)
         self._runner = helpers.Runners(paths)
         self.osfamily = 'debian'
-        self._net_renderer = eni.Renderer({
-            'eni_path': self.network_conf_fn,
-            'eni_header': ENI_HEADER,
-            'links_path_prefix': None,
-            'netrules_path': None,
-        })
+        self._net_renderer = None
 
     def apply_locale(self, locale, out_fn=None):
         if not out_fn:
@@ -76,11 +77,40 @@ class Distro(distros.Distro):
         self.package_command('install', pkgs=pkglist)
 
     def _write_network(self, settings):
-        util.write_file(self.network_conf_fn, settings)
+        # this is always going to be eni based
+        util.write_file(self.network_conf_fn[1], settings)
         return ['all']
 
+    def _select_net_renderer(self, network_state):
+        # This method will encapsulate the policy
+        # by which $distro determines which render
+        # to use.
+
+        # In Ubuntu, we only use v2 iff the target
+        # system has netplan and networkd available
+        netplan_support = _netplan_supported()
+
+        if netplan_support is True:
+            LOG.debug('Selected network config renderer: netplan')
+            net_renderer = netplan.Renderer({
+                'netplan_path': self.network_conf_fn[2],
+                'netplan_header': ENI_HEADER,
+                'postcmds': [NETPLAN_GENERATE]
+            })
+        else:
+            LOG.debug('Selected network config renderer: eni')
+            net_renderer = eni.Renderer({
+                'eni_path': self.network_conf_fn[1],
+                'eni_header': ENI_HEADER,
+                'links_path_prefix': None,
+                'netrules_path': None,
+            })
+
+        return net_renderer
+
     def _write_network_config(self, netconfig):
         ns = parse_net_config_data(netconfig)
+        self._net_renderer = self._select_net_renderer(ns)
         self._net_renderer.render_network_state("/", ns)
         _maybe_remove_legacy_eth0()
         return []
@@ -223,4 +253,18 @@ def _maybe_remove_legacy_eth0(path="/etc/network/interfaces.d/eth0.cfg"):
 
     LOG.warn(msg)
 
-# vi: ts=4 expandtab
+
+def _netplan_supported():
+    """Check if current Distro supports netplan.
+
+    Returns:
+        True if Distro supports netplan, otherwise False
+    """
+
+    nplan = util.which('netplan')
+    if nplan is None:
+        LOG.debug('netplan support: False')
+        return False
+
+    LOG.debug('netplan support: True')
+    return True
diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py
index ea649cc..e2a50ad 100755
--- a/cloudinit/net/__init__.py
+++ b/cloudinit/net/__init__.py
@@ -14,6 +14,7 @@ from cloudinit import util
 
 LOG = logging.getLogger(__name__)
 SYS_CLASS_NET = "/sys/class/net/"
+SYS_DEV_VIRT_NET = "/sys/devices/virtual/net/"
 DEFAULT_PRIMARY_INTERFACE = 'eth0'
 
 
@@ -205,7 +206,11 @@ def _get_current_rename_info(check_downable=True):
     """Collect information necessary for rename_interfaces."""
     names = get_devicelist()
     bymac = {}
+    virtual = os.listdir(SYS_DEV_VIRT_NET)
     for n in names:
+        # do not attempt to rename virtual interfaces
+        if n in virtual:
+            continue
         bymac[get_interface_mac(n)] = {
             'name': n, 'up': is_up(n), 'downable': None}
 
diff --git a/cloudinit/net/eni.py b/cloudinit/net/eni.py
index 5b249f1..c1cb182 100644
--- a/cloudinit/net/eni.py
+++ b/cloudinit/net/eni.py
@@ -8,6 +8,7 @@ import re
 from . import ParserError
 
 from . import renderer
+from .network_state import subnet_is_ipv6
 
 from cloudinit import util
 
@@ -111,16 +112,6 @@ def _iface_start_entry(iface, index, render_hwaddress=False):
     return lines
 
 
-def _subnet_is_ipv6(subnet):
-    # 'static6' or 'dhcp6'
-    if subnet['type'].endswith('6'):
-        # This is a request for DHCPv6.
-        return True
-    elif subnet['type'] == 'static' and ":" in subnet['address']:
-        return True
-    return False
-
-
 def _parse_deb_config_data(ifaces, contents, src_dir, src_path):
     """Parses the file contents, placing result into ifaces.
 
@@ -367,7 +358,7 @@ class Renderer(renderer.Renderer):
                 iface['mode'] = subnet['type']
                 iface['control'] = subnet.get('control', 'auto')
                 subnet_inet = 'inet'
-                if _subnet_is_ipv6(subnet):
+                if subnet_is_ipv6(subnet):
                     subnet_inet += '6'
                 iface['inet'] = subnet_inet
                 if subnet['type'].startswith('dhcp'):
diff --git a/cloudinit/net/netplan.py b/cloudinit/net/netplan.py
new file mode 100644
index 0000000..80d5c5d
--- /dev/null
+++ b/cloudinit/net/netplan.py
@@ -0,0 +1,343 @@
+# 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 copy
+import os
+from textwrap import indent
+
+from . import renderer
+from .network_state import subnet_is_ipv6
+
+from cloudinit import util
+from cloudinit.net import SYS_CLASS_NET
+
+
+NET_CONFIG_COMMANDS = [
+    "pre-up", "up", "post-up", "down", "pre-down", "post-down",
+]
+
+NET_CONFIG_BRIDGE_OPTIONS = [
+    "bridge_ageing", "bridge_bridgeprio", "bridge_fd", "bridge_gcinit",
+    "bridge_hello", "bridge_maxage", "bridge_maxwait", "bridge_stp",
+]
+
+NET_CONFIG_OPTIONS = [
+    "address", "netmask", "broadcast", "network", "metric", "gateway",
+    "pointtopoint", "media", "mtu", "hostname", "leasehours", "leasetime",
+    "vendor", "client", "bootfile", "server", "hwaddr", "provider", "frame",
+    "netnum", "endpoint", "local", "ttl",
+]
+
+NET_CONFIG_TO_V2 = {
+    'bridge': {
+        'bridge_ageing': 'ageing-time',
+        'bridge_bridgeprio': 'priority',
+        'bridge_fd': 'forward-delay',
+        'bridge_gcint': None,
+        'bridge_hello': 'hello-time',
+        'bridge_maxage': 'max-age',
+        'bridge_maxwait': None,
+        'bridge_pathcost': 'path-cost',
+        'bridge_portprio': None,
+        'bridge_waitport': None,
+    },
+    'bond': {
+		'bond-ad-select': 'ad-select',
+		'bond-arp-interval': 'arp-interval',
+		'bond-arp-ip-target': 'arp-ip-target',
+		'bond-arp-validate': 'arp-validate',
+		'bond-downdelay': 'down-delay',
+		'bond-fail-over-mac': 'fail-over-mac-policy',
+		'bond-lacp-rate': 'lacp-rate',
+		'bond-miimon': 'mii-monitor-interval',
+		'bond-min-links': 'min-links',
+		'bond-mode': 'mode',
+		'bond-num-grat-arp': 'gratuitious-arp',
+		'bond-primary-reselect': 'primary-reselect-policy',
+		'bond-updelay': 'up-delay',
+		'bond-xmit_hash_policy': 'transmit_hash_policy',
+	},
+}
+
+
+def _get_params_dict_by_match(config, match):
+    return dict((key, value) for (key, value) in config.items()
+                if key.startswith(match))
+
+
+def _extract_addresses(config, entry):
+    addresses = []
+    routes = []
+    nameservers = []
+    searchdomains = []
+    subnets = config.get('subnets', [])
+    if subnets is None:
+        subnets = []
+    for subnet in subnets:
+        sn_type = subnet.get('type')
+        if sn_type.startswith('dhcp'):
+            entry.update({sn_type: True})
+        elif sn_type in ['static']:
+            addr = "%s" % subnet.get('address')
+            if 'netmask' in subnet:
+                addr += "/%s" % subnet.get('netmask')
+            if 'gateway' in subnet and subnet.get('gateway'):
+                gateway = subnet.get('gateway')
+                if ":" in gateway:
+                    entry.update({'gateway6': gateway})
+                else:
+                    entry.update({'gateway4': gateway})
+            if 'dns_nameservers' in subnet:
+                nameservers += subnet.get('dns_nameservers', [])
+            if 'dns_search' in subnet:
+                searchdomains += subnet.get('dns_search', [])
+            if 'mtu' in subnet:
+                mtukey = 'mtu'
+                if _subnet_is_ipv6(subnet):
+                    mtukey += '6'
+                entry.update({mtukey: subnet.get('mtu')})
+            for route in subnet.get('routes', []):
+                to_net = "%s/%s" % (route.get('network'),
+                                    route.get('netmask'))
+                route = {
+                    'via': route.get('gateway'),
+                    'to': to_net,
+                }
+                if 'metric' in route:
+                    route.update({'metric': route.get('metric', 100)})
+                routes.append(route)
+
+            addresses.append(addr)
+
+    if len(addresses) > 0:
+        entry.update({'addresses': addresses})
+    if len(routes) > 0:
+        entry.update({'routes': routes})
+    if len(nameservers) > 0:
+        ns = {'addresses': nameservers}
+        entry.update({'nameservers': ns})
+    if len(searchdomains) > 0:
+        ns = entry.get('nameservers', {})
+        ns.update({'search': searchdomains})
+        entry.update({'nameservers': ns})
+
+
+def _extract_bond_slaves_by_name(interfaces, entry, bond_master):
+    bond_slave_names = [name for (name, cfg) in interfaces.items()
+                        if cfg.get('bond-master', None) == bond_master]
+    if len(bond_slave_names) > 0:
+        entry.update({'interfaces': bond_slave_names})
+
+
+class Renderer(renderer.Renderer):
+    """Renders network information in a /etc/netplan/network.yaml format."""
+
+    def __init__(self, config=None):
+        if not config:
+            config = {}
+        self.netplan_path = config.get('netplan_path',
+                                       'etc/netplan/50-cloud-init.yaml')
+        self.netplan_header = config.get('netplan_header', None)
+        self.postcmds = config.get('postcmds', [])
+
+    def render_network_state(self, target, network_state):
+        # 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)
+        util.ensure_dir(os.path.dirname(fpnplan))
+        header = self.netplan_header if self.netplan_header else ""
+
+        if network_state.version > 1:
+            # pass-through original config
+            content = util.yaml_dumps({'network': network_state.config},
+                                      explicit_start=False,
+                                      explicit_end=False)
+        else:
+            # render from state
+            content = self._render_content(network_state)
+            # ensure we poke udev to run net_setup_link
+            if len(self.postcmds) > 0:
+                setup_lnk = ['udevadm', 'test-builtin', 'net_setup_link']
+                self.postcmds += [setup_lnk + [SYS_CLASS_NET + "/%s" % iface]
+                                  for iface in os.listdir(SYS_CLASS_NET) if
+                                  os.path.islink(SYS_CLASS_NET + "/%s" % iface)]
+
+        if not header.endswith("\n"):
+            header += "\n"
+        util.write_file(fpnplan, header + content)
+        for cmd in self.postcmds:
+            out, err = util.subp(cmd, capture=True)
+            print('WARK: %s: stdout:\n%s\nstderr:\n%s' % (cmd, out, err))
+
+    def _render_content(self, network_state):
+        print('rendering v2 for victory!')
+        ethernets = {}
+        wifis = {}
+        bridges = {}
+        bonds = {}
+        vlans = {}
+        content = []
+
+        interfaces = network_state._network_state.get('interfaces', [])
+        order = {
+            'physical': 0,
+            'bond': 1,
+            'bridge': 2,
+            'vlan': 3,
+        }
+
+        nameservers = network_state.dns_nameservers
+        searchdomains = network_state.dns_searchdomains
+
+        for config in sorted(network_state.iter_interfaces(),
+                             key=lambda k: (order[k['type']], k['name'])):
+            ifname = config.get('name')
+            # filter None entries up front so we can do simple if key in dict
+            ifcfg = dict((key, value) for (key, value) in config.items()
+                         if value)
+
+            if_type = ifcfg.get('type')
+            if if_type == 'physical':
+                # required_keys = ['name', 'mac_address']
+                eth = {
+                    'set-name': ifname,
+                    'match': ifcfg.get('match', None),
+                }
+                if eth['match'] is None:
+                    macaddr = ifcfg.get('mac_address', None)
+                    if macaddr is not None:
+                        eth['match'] = {'macaddress': macaddr.lower()}
+                    else:
+                        del eth['match']
+                if 'mtu' in ifcfg:
+                    eth['mtu'] = ifcfg.get('mtu')
+
+                _extract_addresses(ifcfg, eth)
+                ethernets.update({ifname: eth})
+
+            elif if_type == 'bond':
+                # required_keys = ['name', 'bond_interfaces']
+                bond = {}
+                bond_config = {}
+                # extract bond params and drop the bond_ prefix as it's
+                # redundent in v2 yaml format
+                v2_bond_map = NET_CONFIG_TO_V2.get('bond')
+                for match in ['bond_', 'bond-']:
+                    bond_params = _get_params_dict_by_match(ifcfg, match)
+                    for (param, value) in bond_params.items():
+                        newname = v2_bond_map.get(param)
+                        if newname is None:
+                            continue
+                        bond_config.update({newname: value})
+
+                if len(bond_config) > 0:
+                    bond.update({'parameters': bond_config})
+                slave_interfaces = ifcfg.get('bond-slaves')
+                if slave_interfaces == 'none':
+                    _extract_bond_slaves_by_name(interfaces, bond, ifname)
+                _extract_addresses(ifcfg, bond)
+                bonds.update({ifname: bond})
+
+            elif if_type == 'bridge':
+                # required_keys = ['name', 'bridge_ports']
+                bridge = {
+                    'interfaces': copy.copy(ifcfg.get('bridge_ports')),
+                }
+                # extract bridge params and drop the bridge prefix as it's
+                # redundent in v2 yaml format
+                match_prefix = 'bridge_'
+                params = _get_params_dict_by_match(ifcfg, match_prefix)
+                br_config = {}
+
+                # v2 yaml uses different names for the keys
+                # and at least one value format change
+                v2_bridge_map = NET_CONFIG_TO_V2.get('bridge')
+                for (param, value) in params.items():
+                    newname = v2_bridge_map.get(param)
+                    if newname is None:
+                        continue
+                    br_config.update({newname: value})
+                    if newname == 'path-cost':
+                        # <interface> <cost> -> <interface>: int(<cost>)
+                        newvalue = {}
+                        for costval in value:
+                            (port, cost) = costval.split()
+                            newvalue[port] = int(cost)
+                        br_config.update({newname: newvalue})
+                if len(br_config) > 0:
+                    bridge.update({'parameters': br_config})
+                _extract_addresses(ifcfg, bridge)
+                bridges.update({ifname: bridge})
+
+            elif if_type == 'vlan':
+                # required_keys = ['name', 'vlan_id', 'vlan-raw-device']
+                vlan = {
+                    'id': ifcfg.get('vlan_id'),
+                    'link': ifcfg.get('vlan-raw-device')
+                }
+
+                _extract_addresses(ifcfg, vlan)
+                vlans.update({ifname: vlan})
+
+        # inject global nameserver values under each physical interface
+        if nameservers:
+            for eth, cfg in ethernets.items():
+                nscfg = cfg.get('nameservers', {})
+                addresses = nscfg.get('addresses', [])
+                addresses += nameservers
+                nscfg.update({'addresses': addresses})
+                cfg.update({'nameservers': nscfg})
+
+        if searchdomains:
+            for eth, cfg in ethernets.items():
+                nscfg = cfg.get('nameservers', {})
+                search = nscfg.get('search', [])
+                search += searchdomains
+                nscfg.update({'search': search})
+                cfg.update({'nameservers': nscfg})
+
+        # workaround yaml dictionary key sorting when dumping
+        def _render_section(name, section):
+            if section:
+                dump = util.yaml_dumps({name: section},
+                                       explicit_start=False,
+                                       explicit_end=False)
+                txt = indent(dump, ' ' * 4)
+                return [txt]
+            return []
+
+        content.append("network:\n    version: 2\n")
+        content += _render_section('ethernets', ethernets)
+        content += _render_section('wifis', wifis)
+        content += _render_section('bonds', bonds)
+        content += _render_section('bridges', bridges)
+        content += _render_section('vlans', vlans)
+
+        return "".join(content)
+
+
+def network_state_to_netplan(network_state, header=None):
+    # render the provided network state, return a string of equivalent eni
+    netplan_path = 'etc/network/50-cloud-init.yaml'
+    renderer = Renderer({
+        'netplan_path': netplan_path,
+        'netplan_header': header,
+    })
+    if not header:
+        header = ""
+    if not header.endswith("\n"):
+        header += "\n"
+    contents = renderer._render_content(network_state)
+    return header + contents
diff --git a/cloudinit/net/network_state.py b/cloudinit/net/network_state.py
index 11ef585..844e36c 100644
--- a/cloudinit/net/network_state.py
+++ b/cloudinit/net/network_state.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2013-2014 Canonical Ltd.
+# Copyright (C) 2017 Canonical Ltd.
 #
 # Author: Ryan Harper <ryan.harper@xxxxxxxxxxxxx>
 #
@@ -18,6 +18,10 @@ NETWORK_STATE_VERSION = 1
 NETWORK_STATE_REQUIRED_KEYS = {
     1: ['version', 'config', 'network_state'],
 }
+NETWORK_V2_KEY_FILTER = [
+    'addresses', 'dhcp4', 'dhcp6', 'gateway4', 'gateway6', 'interfaces',
+    'match', 'mtu', 'nameservers', 'renderer', 'set-name', 'wakeonlan'
+]
 
 
 def parse_net_config_data(net_config, skip_broken=True):
@@ -26,11 +30,18 @@ def parse_net_config_data(net_config, skip_broken=True):
     :param net_config: curtin network config dict
     """
     state = None
-    if 'version' in net_config and 'config' in net_config:
-        nsi = NetworkStateInterpreter(version=net_config.get('version'),
-                                      config=net_config.get('config'))
+    version = net_config.get('version')
+    config = net_config.get('config')
+    if version == 2:
+        # v2 does not have explicit 'config' key so we
+        # pass the whole net-config as-is
+        config = net_config
+
+    if version and config:
+        nsi = NetworkStateInterpreter(version=version, config=config)
         nsi.parse_config(skip_broken=skip_broken)
-        state = nsi.network_state
+        state = nsi.get_network_state()
+
     return state
 
 
@@ -103,14 +114,20 @@ class CommandHandlerMeta(type):
 
 class NetworkState(object):
 
-    def __init__(self, network_state, version=NETWORK_STATE_VERSION):
+    def __init__(self, network_state, version=NETWORK_STATE_VERSION,
+                 config=None):
         self._network_state = copy.deepcopy(network_state)
         self._version = version
+        self._config = copy.deepcopy(config)
 
     @property
     def version(self):
         return self._version
 
+    @property
+    def config(self):
+        return self._config
+
     def iter_routes(self, filter_func=None):
         for route in self._network_state.get('routes', []):
             if filter_func is not None:
@@ -163,7 +180,8 @@ class NetworkStateInterpreter(object):
 
     @property
     def network_state(self):
-        return NetworkState(self._network_state, version=self._version)
+        return NetworkState(self._network_state, version=self._version,
+                            config=self._config)
 
     def dump(self):
         state = {
@@ -192,8 +210,20 @@ class NetworkStateInterpreter(object):
     def dump_network_state(self):
         return util.yaml_dumps(self._network_state)
 
+    def as_dict(self):
+        return {'version': self.version, 'config': self.config}
+
+    def get_network_state(self):
+        ns = self.network_state
+        return ns
+
     def parse_config(self, skip_broken=True):
-        # rebuild network state
+        if self._version == 1:
+            print('parsing v1 for profit!')
+            self.parse_config_v1(skip_broken=skip_broken)
+            self._parsed = True
+
+    def parse_config_v1(self, skip_broken=True):
         for command in self._config:
             command_type = command['type']
             try:
@@ -211,6 +241,25 @@ class NetworkStateInterpreter(object):
                              exc_info=True)
                     LOG.debug(self.dump_network_state())
 
+    def parse_config_v2(self, skip_broken=True):
+        for command_type, command in self._config.get('network').items():
+
+            try:
+                handler = self.command_handlers[command_type]
+            except KeyError:
+                raise RuntimeError("No handler found for"
+                                   " command '%s'" % command_type)
+            try:
+                handler(self, command)
+                self._v2_common(self, command)
+            except InvalidCommand:
+                if not skip_broken:
+                    raise
+                else:
+                    LOG.warn("Skipping invalid command: %s", command,
+                             exc_info=True)
+                    LOG.debug(self.dump_network_state())
+
     @ensure_command_keys(['name'])
     def handle_physical(self, command):
         '''
@@ -323,7 +372,7 @@ class NetworkStateInterpreter(object):
                 bond_if.update({param: val})
             self._network_state['interfaces'].update({ifname: bond_if})
 
-    @ensure_command_keys(['name', 'bridge_interfaces', 'params'])
+    @ensure_command_keys(['name', 'bridge_interfaces'])
     def handle_bridge(self, command):
         '''
             auto br0
@@ -369,7 +418,7 @@ class NetworkStateInterpreter(object):
         self.handle_physical(command)
         iface = interfaces.get(command.get('name'), {})
         iface['bridge_ports'] = command['bridge_interfaces']
-        for param, val in command.get('params').items():
+        for param, val in command.get('params', {}).items():
             iface.update({param: val})
 
         interfaces.update({iface['name']: iface})
@@ -403,6 +452,239 @@ class NetworkStateInterpreter(object):
         }
         routes.append(route)
 
+    # V2 handlers
+    def handle_bonds(self, command):
+        '''
+        v2_command = {
+          bond0: {
+            'interfaces': ['interface0', 'interface1'],
+            'miimon': 100,
+            'mode': '802.3ad',
+            'xmit_hash_policy': 'layer3+4'},
+          bond1: {
+            'bond-slaves': ['interface2', 'interface7'],
+            'mode': 1
+          }
+        }
+
+        v1_command = {
+            'type': 'bond'
+            'name': 'bond0',
+            'bond_interfaces': [interface0, interface1],
+            'params': {
+                'bond-mode': '802.3ad',
+                'bond_miimon: 100,
+                'bond_xmit_hash_policy': 'layer3+4',
+            }
+        }
+
+        '''
+        self._handle_bond_bridge(command, cmd_type='bond')
+
+    def handle_bridges(self, command):
+
+        '''
+        v2_command = {
+          br0: {
+            'interfaces': ['interface0', 'interface1'],
+            'fd': 0,
+            'stp': 'off',
+            'maxwait': 0,
+          }
+        }
+
+        v1_command = {
+            'type': 'bridge'
+            'name': 'br0',
+            'bridge_interfaces': [interface0, interface1],
+            'params': {
+                'bridge_stp': 'off',
+                'bridge_fd: 0,
+                'bridge_maxwait': 0
+            }
+        }
+
+        '''
+        self._handle_bond_bridge(command, cmd_type='bridge')
+
+    def handle_ethernets(self, command):
+        '''
+        ethernets:
+          eno1:
+            match:
+              macaddress: 00:11:22:33:44:55
+            wakeonlan: true
+            dhcp4: true
+            dhcp6: false
+            addresses:
+              - 192.168.14.2/24
+              - 2001:1::1/64
+            gateway4: 192.168.14.1
+            gateway6: 2001:1::2
+            nameservers:
+              search: [foo.local, bar.local]
+              addresses: [8.8.8.8, 8.8.4.4]
+          lom:
+            match:
+              driver: ixgbe
+            set-name: lom1
+            dhcp6: true
+          switchports:
+            match:
+              name: enp2*
+            mtu: 1280
+
+        command = {
+            'type': 'physical',
+            'mac_address': 'c0:d6:9f:2c:e8:80',
+            'name': 'eth0',
+            'subnets': [
+                {'type': 'dhcp4'}
+             ]
+        }
+        '''
+        for eth, cfg in command.items():
+            phy_cmd = {
+                'type': 'physical',
+                'name': cfg.get('set-name', eth),
+            }
+            mac_address = cfg.get('match', {}).get('macaddress', None)
+            if not mac_address:
+                LOG.warning('NetworkState Version2: missing macaddress')
+
+            for key in ['mtu', 'match', 'wakeonlan']:
+                if key in cfg:
+                    phy_cmd.update({key: cfg.get(key)})
+
+            subnets = self._v2_to_v1_ipcfg(cfg)
+            if len(subnets) > 0:
+                phy_cmd.update({'subnets': subnets})
+
+            LOG.debug('v2(ethernets) -> v1(physical):\n%s', phy_cmd)
+            self.handle_physical(phy_cmd)
+
+    def handle_vlans(self, command):
+        '''
+        v2_vlans = {
+            'eth0.123': {
+                'id': 123,
+                'link': 'eth0',
+                'dhcp4': True,
+            }
+        }
+
+        v1_command = {
+            'type': 'vlan',
+            'name': 'eth0.123',
+            'vlan_link': 'eth0',
+            'vlan_id': 123,
+            'subnets': [{'type': 'dhcp4'}],
+        }
+        '''
+        for vlan, cfg in command.items():
+            vlan_cmd = {
+                'type': 'vlan',
+                'name': vlan,
+                'vlan_id': cfg.get('id'),
+                'vlan_link': cfg.get('link'),
+            }
+            subnets = self._v2_to_v1_ipcfg(cfg)
+            if len(subnets) > 0:
+                vlan_cmd.update({'subnets': subnets})
+            LOG.debug('v2(vlans) -> v1(vlan):\n%s', vlan_cmd)
+            self.handle_vlan(vlan_cmd)
+
+    def handle_wifis(self, command):
+        LOG.warning('NetworkState V2: Skipping wifi configuration')
+        pass
+
+    def _v2_common(self, cfg):
+        LOG.debug('v2_common: handling config:\n%s', cfg)
+        if 'nameservers' in cfg:
+            search = cfg.get('nameservers').get('search', [])
+            dns = cfg.get('nameservers').get('addresses', [])
+            name_cmd = {'type': 'nameserver'}
+            if len(search) > 0:
+                name_cmd.update({'search': search})
+            if len(dns) > 0:
+                name_cmd.update({'addresses': dns})
+            LOG.debug('v2(nameserver) -> v1(nameserver):\n%s', name_cmd)
+            self.handle_nameserver(name_cmd)
+
+    def _handle_bond_bridge(self, command, cmd_type=None):
+        """Common handler for bond and bridge types"""
+        for item_name, item_cfg in command.items():
+            item_params = dict((key, value) for (key, value) in
+                               item_cfg.items() if key not in
+                               NETWORK_V2_KEY_FILTER)
+            v1_cmd = {
+                'type': cmd_type,
+                'name': item_name,
+                cmd_type + '_interfaces': item_cfg.get('interfaces'),
+                'params': item_params,
+            }
+            subnets = self._v2_to_v1_ipcfg(item_cfg)
+            if len(subnets) > 0:
+                v1_cmd.update({'subnets': subnets})
+
+            LOG.debug('v2(%ss) -> v1(%s):\n%s', cmd_type, cmd_type, v1_cmd)
+            self.handle_bridge(v1_cmd)
+
+    def _v2_to_v1_ipcfg(self, cfg):
+        """Common ipconfig extraction from v2 to v1 subnets array."""
+
+        subnets = []
+        if 'dhcp4' in cfg:
+            subnets.append({'type': 'dhcp4'})
+        if 'dhcp6' in cfg:
+            subnets.append({'type': 'dhcp6'})
+
+        gateway4 = None
+        gateway6 = None
+        for address in cfg.get('addresses', []):
+            subnet = {
+                'type': 'static',
+                'address': address,
+            }
+
+            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})
+
+            if ":" in address:
+                if 'gateway6' in cfg and gateway6 is None:
+                    gateway6 = cfg.get('gateway6')
+                    subnet.update({'gateway': gateway6})
+            else:
+                if 'gateway4' in cfg and gateway4 is None:
+                    gateway4 = cfg.get('gateway4')
+                    subnet.update({'gateway': gateway4})
+
+            subnets.append(subnet)
+        return subnets
+
+
+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']:
+        return True
+    return False
+
 
 def cidr2mask(cidr):
     mask = [0, 0, 0, 0]
diff --git a/systemd/cloud-init.service b/systemd/cloud-init.service
index fb3b918..39acc20 100644
--- a/systemd/cloud-init.service
+++ b/systemd/cloud-init.service
@@ -5,6 +5,7 @@ Wants=cloud-init-local.service
 Wants=sshd-keygen.service
 Wants=sshd.service
 After=cloud-init-local.service
+After=systemd-networkd-wait-online.service
 After=networking.service
 Before=network-online.target
 Before=sshd-keygen.service
diff --git a/systemd/systemd-networkd-wait-online.path b/systemd/systemd-networkd-wait-online.path
new file mode 100644
index 0000000..64940b8
--- /dev/null
+++ b/systemd/systemd-networkd-wait-online.path
@@ -0,0 +1,5 @@
+[Unit]
+Description=Trigger systemd-networkd-wait-online if netplan runs/updates
+
+[Path]
+PathChanged=/run/systemd/generator/netplan.stamp
diff --git a/tools/net-convert.py b/tools/net-convert.py
new file mode 100755
index 0000000..0a76dc2
--- /dev/null
+++ b/tools/net-convert.py
@@ -0,0 +1,102 @@
+#!/usr/bin/python3
+# vi: ts=4 expandtab
+#
+#    Copyright (C) 2012 Canonical Ltd.
+#    Copyright (C) 2012 Hewlett-Packard Development Company, L.P.
+#    Copyright (C) 2012 Yahoo! Inc.
+#
+#    Author: Scott Moser <scott.moser@xxxxxxxxxxxxx>
+#    Author: Juerg Haefliger <juerg.haefliger@xxxxxx>
+#    Author: Joshua Harlow <harlowja@xxxxxxxxxxxxx>
+#
+#    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 argparse
+import json
+import os
+import yaml
+
+from cloudinit.sources.helpers import openstack
+
+from cloudinit.net import eni
+from cloudinit.net import network_state
+from cloudinit.net import netplan
+from cloudinit.net import sysconfig
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--network-data", "-p", type=open,
+                        metavar="PATH", required=True)
+    parser.add_argument("--kind", "-k",
+                        choices=['eni', 'network_data.json', 'yaml'],
+                        required=True)
+    parser.add_argument("-d", "--directory",
+                        metavar="PATH",
+                        help="directory to place output in",
+                        required=True)
+    parser.add_argument("-m", "--mac",
+                        metavar="name,mac",
+                        action='append',
+                        help="interface name to mac mapping")
+    parser.add_argument("--output-kind", "-ok",
+                        choices=['eni', 'netplan', 'sysconfig'],
+                        required=True)
+    args = parser.parse_args()
+
+    if not os.path.isdir(args.directory):
+        os.makedirs(args.directory)
+
+    if args.mac:
+        known_macs = {}
+        for item in args.mac:
+            iface_name, iface_mac = item.split(",", 1)
+            known_macs[iface_mac] = iface_name
+    else:
+        known_macs = None
+
+    net_data = args.network_data.read()
+    if args.kind == "eni":
+        pre_ns = eni.convert_eni_data(net_data)
+        ns = network_state.parse_net_config_data(pre_ns)
+    elif args.kind == "yaml":
+        pre_ns = yaml.load(net_data)
+        if 'network' in pre_ns:
+            pre_ns = pre_ns.get('network')
+        print("Input YAML")
+        print(yaml.dump(pre_ns, default_flow_style=False, indent=4))
+        ns = network_state.parse_net_config_data(pre_ns)
+    else:
+        pre_ns = openstack.convert_net_json(
+            json.loads(net_data), known_macs=known_macs)
+        ns = network_state.parse_net_config_data(pre_ns)
+
+    if not ns:
+        raise RuntimeError("No valid network_state object created from"
+                           "input data")
+
+    print("\nInternal State")
+    print(yaml.dump(ns, default_flow_style=False, indent=4))
+    if args.output_kind == "eni":
+        r_cls = eni.Renderer
+    elif args.output_kind == "netplan":
+        r_cls = netplan.Renderer
+    else:
+        r_cls = sysconfig.Renderer
+
+    r = r_cls()
+    r.render_network_state(args.directory, ns)
+
+
+if __name__ == '__main__':
+    main()

References