← Back to team overview

cloud-init-dev team mailing list archive

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

 

Thanks for the review

Diff comments:

> 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
> @@ -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"
> +    }

integers map the the network config version.  1 == eni, 2 = netplan.
I may have in a previous iteration done something like:

conf_fn = network_conf_fn.get(config.get('version'))

I'm fine to change however is best;

>  
>      def __init__(self, name, cfg, paths):
>          distros.Distro.__init__(self, name, cfg, paths)
> @@ -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:

ACK

> +            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 []
> 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
> @@ -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

I've updated the bug as well; tl;dr is then we need to find the duplicates and have some way to determine which of the N interfaces sharing the MAC are rename'able.

>          bymac[get_interface_mac(n)] = {
>              'name': n, 'up': is_up(n), 'downable': None}
>  
> 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

ACK

> +#
> +#    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",
> +]

ACK; left over from copy of eni.py

> +
> +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):

ACK

> +    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', [])

I don't think we'll keep it.  We'll have 'netplan generate' for sure
and likely some explicit kick for .link interfaces; note that 'netplan apply'
isn't want we want since that (at least for now) attempts to start networkd
itself and replug network devices (which we don't need).

> +
> +    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)]

Yeah;  mostly helpful for playing with it.  I think we're close to
settling on fixed commands to run in the netplan path only.

> +
> +        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
> +

We can drop the order; this was related to eni rendering; as it
turns out netplan has it's own order that is needed (though hopefully
with the forward declaration fix in netplan, we can just do
util.write_file((util.yaml_dump()).

> +        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 = {}

eni supports both '-' and '_' so v1 supported both; hence network state
may have one or the other.  We could push a single state into network_state
and then render only _ or whatever is required in eni.

> +                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():

ACK

> +                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})
> +

You forgot:

 ::smoser the _render_section stuff could be added as a method
    render_ordered_yaml or something.

    render_ordered(indent=4,
        (('ethernets', ethernets),
         ('wifis', wifis),
         ('bonds', bonds))

    kind of seems generally useful.

To which I replied:

Well, we _plan_ to drop it; netplan should not require an order
for parsing it; that's supposedly fixed in 0.19; I've yet to
test it.

> +        # 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):

Well, we already have a render_network_state in the Renderer class; this is a non-class method for use outside of the Distro configured renderer object.  We've a similar helper in eni.py; none in sysconfig.py

> +    # 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
> @@ -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

ACK

> +
> +    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/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

Maybe i'm just missing something, but I'm not sure how this works.
    It seems that the .path entry here will start
    systemd-networkd-wait-online.service when netplan.stamp is created or
    modified.

No; we _always_ wait on systemd-networkd-wait-online.service;  Without
a valid networkd config (nothing /etc/systemd/network or /run/systemd/network
the wait-online service is a noop.

    But I'm not really sure how that works.  I guess on creation of that
    file, systemd must re-load its generators, and then decide that
    cloud-init.service should wait on systemd-networkd-wait-online.

It's certianly fine to the service at any time; we don't care as it's a noop without
a networkd configuration file and networkd actually running.

    What happens on a system with systemd-networkd and no netplan?

If a user has configured networkd; they they will have enabled systemd-networkd
via a .wants target somewhere;  this means that networkd will run before
cloud-init.service, and so will systemd-networkd-wait-online.service.
With no netplan, the .path file is never triggered.

    Maybe we need netplan to provide us with a correct analog to
    networking.service.

I think we're fine here; but I'm willing to change if we find a
better way to handle all of our cases.

     - i think we might want PathExists rather than PathChanged per:
       https://www.freedesktop.org/software/systemd/man/systemd.path.html

We'd like to be called whenever it changes.  For example, if there was a
built-in netplan config and then cloud-init generates an additional config
we'd want cloud-init's call to netplan-generate to update the .stampfile
and trigger the wait.



-- 
https://code.launchpad.net/~raharper/cloud-init/+git/cloud-init/+merge/319259
Your team cloud init development team is requested to review the proposed merge of ~raharper/cloud-init:netconfig-v2-passthrough into cloud-init:master.


References