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. _______________________________________________ Mailing list: https://launchpad.net/~cloud-init-dev Post to : cloud-init-dev@lists.launchpad.net Unsubscribe : https://launchpad.net/~cloud-init-dev More help : https://help.launchpad.net/ListHelp