ad9e5f
From 0d93e53fd05c44b62e3456b7580c9de8135e6b5a Mon Sep 17 00:00:00 2001
ad9e5f
From: Emanuele Giuseppe Esposito <eesposit@redhat.com>
ad9e5f
Date: Mon, 2 May 2022 14:21:24 +0200
ad9e5f
Subject: [PATCH 1/4] Add native NetworkManager support (#1224)
ad9e5f
ad9e5f
RH-Author: Emanuele Giuseppe Esposito <eesposit@redhat.com>
ad9e5f
RH-MergeRequest: 57: Add native NetworkManager support (#1224)
ad9e5f
RH-Commit: [1/2] 56b9ed40840a4930c421c2749e8aa385097bef93
ad9e5f
RH-Bugzilla: 2059872
ad9e5f
RH-Acked-by: Vitaly Kuznetsov <vkuznets@redhat.com>
ad9e5f
RH-Acked-by: Jon Maloy <jmaloy@redhat.com>
ad9e5f
RH-Acked-by: Eduardo Otubo <otubo@redhat.com>
ad9e5f
ad9e5f
commit feda344e6cf9d37b09bc13cf333a717d1654c26c
ad9e5f
Author: Lubomir Rintel <lkundrak@v3.sk>
ad9e5f
Date:   Fri Feb 25 23:33:20 2022 +0100
ad9e5f
ad9e5f
    Add native NetworkManager support (#1224)
ad9e5f
ad9e5f
    Fedora currently relies on sysconfig/ifcfg renderer. This is not too great,
ad9e5f
    because Fedora (also RHEL since version 8) dropped support for the legacy
ad9e5f
    network service that uses ifcfg files long ago.
ad9e5f
ad9e5f
    In turn, Fedora ended up patching cloud-init downstream to utilize
ad9e5f
    NetworkManager's ifcfg compatibility mode [1]. This seems to have worked
ad9e5f
    for a while, nevertheless the NetworkManager's ifcfg backend is reaching
ad9e5f
    the end of its useful life too [2].
ad9e5f
ad9e5f
    [1] https://src.fedoraproject.org/rpms/cloud-init/blob/rawhide/f/cloud-init-21.3-nm-controlled.patch
ad9e5f
    [2] https://fedoraproject.org/wiki/Changes/NoIfcfgFiles
ad9e5f
ad9e5f
    Let's not mangle things downstream and make vanilla cloud-init work great
ad9e5f
    on Fedora instead.
ad9e5f
ad9e5f
    This also means that the sysconfig compatibility with
ad9e5f
    Network Manager was removed.
ad9e5f
ad9e5f
    Firstly, this relies upon the fact that you can get ifcfg support by adding
ad9e5f
    it to NetworkManager.conf. That is not guaranteed and certainly will not
ad9e5f
    be case in future.
ad9e5f
ad9e5f
    Secondly, cloud-init always generates configuration with
ad9e5f
    NM_CONTROLLED=no, so the generated ifcfg files are no good for
ad9e5f
    NetworkManager. Fedora patches around this by just removing those lines
ad9e5f
    in their cloud-init package.
ad9e5f
ad9e5f
Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
ad9e5f
---
ad9e5f
 cloudinit/cmd/devel/net_convert.py     |   14 +-
ad9e5f
 cloudinit/net/activators.py            |   25 +-
ad9e5f
 cloudinit/net/network_manager.py       |  377 +++++++
ad9e5f
 cloudinit/net/renderers.py             |    3 +
ad9e5f
 cloudinit/net/sysconfig.py             |   37 +-
ad9e5f
 tests/unittests/test_net.py            | 1270 +++++++++++++++++++++---
ad9e5f
 tests/unittests/test_net_activators.py |   93 +-
ad9e5f
 7 files changed, 1625 insertions(+), 194 deletions(-)
ad9e5f
 create mode 100644 cloudinit/net/network_manager.py
ad9e5f
ad9e5f
diff --git a/cloudinit/cmd/devel/net_convert.py b/cloudinit/cmd/devel/net_convert.py
ad9e5f
index 18b1e7ff..647fe07b 100755
ad9e5f
--- a/cloudinit/cmd/devel/net_convert.py
ad9e5f
+++ b/cloudinit/cmd/devel/net_convert.py
ad9e5f
@@ -7,7 +7,14 @@ import os
ad9e5f
 import sys
ad9e5f
 
ad9e5f
 from cloudinit import distros, log, safeyaml
ad9e5f
-from cloudinit.net import eni, netplan, network_state, networkd, sysconfig
ad9e5f
+from cloudinit.net import (
ad9e5f
+    eni,
ad9e5f
+    netplan,
ad9e5f
+    network_manager,
ad9e5f
+    network_state,
ad9e5f
+    networkd,
ad9e5f
+    sysconfig,
ad9e5f
+)
ad9e5f
 from cloudinit.sources import DataSourceAzure as azure
ad9e5f
 from cloudinit.sources import DataSourceOVF as ovf
ad9e5f
 from cloudinit.sources.helpers import openstack
ad9e5f
@@ -74,7 +81,7 @@ def get_parser(parser=None):
ad9e5f
     parser.add_argument(
ad9e5f
         "-O",
ad9e5f
         "--output-kind",
ad9e5f
-        choices=["eni", "netplan", "networkd", "sysconfig"],
ad9e5f
+        choices=["eni", "netplan", "networkd", "sysconfig", "network-manager"],
ad9e5f
         required=True,
ad9e5f
         help="The network config format to emit",
ad9e5f
     )
ad9e5f
@@ -148,6 +155,9 @@ def handle_args(name, args):
ad9e5f
     elif args.output_kind == "sysconfig":
ad9e5f
         r_cls = sysconfig.Renderer
ad9e5f
         config = distro.renderer_configs.get("sysconfig")
ad9e5f
+    elif args.output_kind == "network-manager":
ad9e5f
+        r_cls = network_manager.Renderer
ad9e5f
+        config = distro.renderer_configs.get("network-manager")
ad9e5f
     else:
ad9e5f
         raise RuntimeError("Invalid output_kind")
ad9e5f
 
ad9e5f
diff --git a/cloudinit/net/activators.py b/cloudinit/net/activators.py
ad9e5f
index e80c26df..edbc0c06 100644
ad9e5f
--- a/cloudinit/net/activators.py
ad9e5f
+++ b/cloudinit/net/activators.py
ad9e5f
@@ -1,15 +1,14 @@
ad9e5f
 # This file is part of cloud-init. See LICENSE file for license information.
ad9e5f
 import logging
ad9e5f
-import os
ad9e5f
 from abc import ABC, abstractmethod
ad9e5f
 from typing import Iterable, List, Type
ad9e5f
 
ad9e5f
 from cloudinit import subp, util
ad9e5f
 from cloudinit.net.eni import available as eni_available
ad9e5f
 from cloudinit.net.netplan import available as netplan_available
ad9e5f
+from cloudinit.net.network_manager import available as nm_available
ad9e5f
 from cloudinit.net.network_state import NetworkState
ad9e5f
 from cloudinit.net.networkd import available as networkd_available
ad9e5f
-from cloudinit.net.sysconfig import NM_CFG_FILE
ad9e5f
 
ad9e5f
 LOG = logging.getLogger(__name__)
ad9e5f
 
ad9e5f
@@ -124,20 +123,24 @@ class IfUpDownActivator(NetworkActivator):
ad9e5f
 class NetworkManagerActivator(NetworkActivator):
ad9e5f
     @staticmethod
ad9e5f
     def available(target=None) -> bool:
ad9e5f
-        """Return true if network manager can be used on this system."""
ad9e5f
-        config_present = os.path.isfile(
ad9e5f
-            subp.target_path(target, path=NM_CFG_FILE)
ad9e5f
-        )
ad9e5f
-        nmcli_present = subp.which("nmcli", target=target)
ad9e5f
-        return config_present and bool(nmcli_present)
ad9e5f
+        """Return true if NetworkManager can be used on this system."""
ad9e5f
+        return nm_available(target=target)
ad9e5f
 
ad9e5f
     @staticmethod
ad9e5f
     def bring_up_interface(device_name: str) -> bool:
ad9e5f
-        """Bring up interface using nmcli.
ad9e5f
+        """Bring up connection using nmcli.
ad9e5f
 
ad9e5f
         Return True is successful, otherwise return False
ad9e5f
         """
ad9e5f
-        cmd = ["nmcli", "connection", "up", "ifname", device_name]
ad9e5f
+        from cloudinit.net.network_manager import conn_filename
ad9e5f
+
ad9e5f
+        filename = conn_filename(device_name)
ad9e5f
+        cmd = ["nmcli", "connection", "load", filename]
ad9e5f
+        if _alter_interface(cmd, device_name):
ad9e5f
+            cmd = ["nmcli", "connection", "up", "filename", filename]
ad9e5f
+        else:
ad9e5f
+            _alter_interface(["nmcli", "connection", "reload"], device_name)
ad9e5f
+            cmd = ["nmcli", "connection", "up", "ifname", device_name]
ad9e5f
         return _alter_interface(cmd, device_name)
ad9e5f
 
ad9e5f
     @staticmethod
ad9e5f
@@ -146,7 +149,7 @@ class NetworkManagerActivator(NetworkActivator):
ad9e5f
 
ad9e5f
         Return True is successful, otherwise return False
ad9e5f
         """
ad9e5f
-        cmd = ["nmcli", "connection", "down", device_name]
ad9e5f
+        cmd = ["nmcli", "device", "disconnect", device_name]
ad9e5f
         return _alter_interface(cmd, device_name)
ad9e5f
 
ad9e5f
 
ad9e5f
diff --git a/cloudinit/net/network_manager.py b/cloudinit/net/network_manager.py
ad9e5f
new file mode 100644
ad9e5f
index 00000000..79b0fe0b
ad9e5f
--- /dev/null
ad9e5f
+++ b/cloudinit/net/network_manager.py
ad9e5f
@@ -0,0 +1,377 @@
ad9e5f
+# Copyright 2022 Red Hat, Inc.
ad9e5f
+#
ad9e5f
+# Author: Lubomir Rintel <lkundrak@v3.sk>
ad9e5f
+# Fixes and suggestions contributed by James Falcon, Neal Gompa,
ad9e5f
+# Zbigniew Jędrzejewski-Szmek and Emanuele Giuseppe Esposito.
ad9e5f
+#
ad9e5f
+# This file is part of cloud-init. See LICENSE file for license information.
ad9e5f
+
ad9e5f
+import configparser
ad9e5f
+import io
ad9e5f
+import itertools
ad9e5f
+import os
ad9e5f
+import uuid
ad9e5f
+
ad9e5f
+from cloudinit import log as logging
ad9e5f
+from cloudinit import subp, util
ad9e5f
+
ad9e5f
+from . import renderer
ad9e5f
+from .network_state import is_ipv6_addr, subnet_is_ipv6
ad9e5f
+
ad9e5f
+NM_RUN_DIR = "/etc/NetworkManager"
ad9e5f
+NM_LIB_DIR = "/usr/lib/NetworkManager"
ad9e5f
+LOG = logging.getLogger(__name__)
ad9e5f
+
ad9e5f
+
ad9e5f
+class NMConnection:
ad9e5f
+    """Represents a NetworkManager connection profile."""
ad9e5f
+
ad9e5f
+    def __init__(self, con_id):
ad9e5f
+        """
ad9e5f
+        Initializes the connection with some very basic properties,
ad9e5f
+        notably the UUID so that the connection can be referred to.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        # Chosen by fair dice roll
ad9e5f
+        CI_NM_UUID = uuid.UUID("a3924cb8-09e0-43e9-890b-77972a800108")
ad9e5f
+
ad9e5f
+        self.config = configparser.ConfigParser()
ad9e5f
+        # Identity option name mapping, to achieve case sensitivity
ad9e5f
+        self.config.optionxform = str
ad9e5f
+
ad9e5f
+        self.config["connection"] = {
ad9e5f
+            "id": f"cloud-init {con_id}",
ad9e5f
+            "uuid": str(uuid.uuid5(CI_NM_UUID, con_id)),
ad9e5f
+        }
ad9e5f
+
ad9e5f
+        # This is not actually used anywhere, but may be useful in future
ad9e5f
+        self.config["user"] = {
ad9e5f
+            "org.freedesktop.NetworkManager.origin": "cloud-init"
ad9e5f
+        }
ad9e5f
+
ad9e5f
+    def _set_default(self, section, option, value):
ad9e5f
+        """
ad9e5f
+        Sets a property unless it's already set, ensuring the section
ad9e5f
+        exists.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        if not self.config.has_section(section):
ad9e5f
+            self.config[section] = {}
ad9e5f
+        if not self.config.has_option(section, option):
ad9e5f
+            self.config[section][option] = value
ad9e5f
+
ad9e5f
+    def _set_ip_method(self, family, subnet_type):
ad9e5f
+        """
ad9e5f
+        Ensures there's appropriate [ipv4]/[ipv6] for given family
ad9e5f
+        appropriate for given configuration type
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        method_map = {
ad9e5f
+            "static": "manual",
ad9e5f
+            "dhcp6": "dhcp",
ad9e5f
+            "ipv6_slaac": "auto",
ad9e5f
+            "ipv6_dhcpv6-stateless": "auto",
ad9e5f
+            "ipv6_dhcpv6-stateful": "auto",
ad9e5f
+            "dhcp4": "auto",
ad9e5f
+            "dhcp": "auto",
ad9e5f
+        }
ad9e5f
+
ad9e5f
+        # Ensure we got an [ipvX] section
ad9e5f
+        self._set_default(family, "method", "disabled")
ad9e5f
+
ad9e5f
+        try:
ad9e5f
+            method = method_map[subnet_type]
ad9e5f
+        except KeyError:
ad9e5f
+            # What else can we do
ad9e5f
+            method = "auto"
ad9e5f
+            self.config[family]["may-fail"] = "true"
ad9e5f
+
ad9e5f
+        # Make sure we don't "downgrade" the method in case
ad9e5f
+        # we got conflicting subnets (e.g. static along with dhcp)
ad9e5f
+        if self.config[family]["method"] == "dhcp":
ad9e5f
+            return
ad9e5f
+        if self.config[family]["method"] == "auto" and method == "manual":
ad9e5f
+            return
ad9e5f
+
ad9e5f
+        self.config[family]["method"] = method
ad9e5f
+        self._set_default(family, "may-fail", "false")
ad9e5f
+        if family == "ipv6":
ad9e5f
+            self._set_default(family, "addr-gen-mode", "stable-privacy")
ad9e5f
+
ad9e5f
+    def _add_numbered(self, section, key_prefix, value):
ad9e5f
+        """
ad9e5f
+        Adds a numbered property, such as address<n> or route<n>, ensuring
ad9e5f
+        the appropriate value gets used for <n>.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        for index in itertools.count(1):
ad9e5f
+            key = f"{key_prefix}{index}"
ad9e5f
+            if not self.config.has_option(section, key):
ad9e5f
+                self.config[section][key] = value
ad9e5f
+                break
ad9e5f
+
ad9e5f
+    def _add_address(self, family, subnet):
ad9e5f
+        """
ad9e5f
+        Adds an ipv[46]address<n> property.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        value = subnet["address"] + "/" + str(subnet["prefix"])
ad9e5f
+        self._add_numbered(family, "address", value)
ad9e5f
+
ad9e5f
+    def _add_route(self, family, route):
ad9e5f
+        """
ad9e5f
+        Adds a ipv[46].route<n> property.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        value = route["network"] + "/" + str(route["prefix"])
ad9e5f
+        if "gateway" in route:
ad9e5f
+            value = value + "," + route["gateway"]
ad9e5f
+        self._add_numbered(family, "route", value)
ad9e5f
+
ad9e5f
+    def _add_nameserver(self, dns):
ad9e5f
+        """
ad9e5f
+        Extends the ipv[46].dns property with a name server.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        # FIXME: the subnet contains IPv4 and IPv6 name server mixed
ad9e5f
+        # together. We might be getting an IPv6 name server while
ad9e5f
+        # we're dealing with an IPv4 subnet. Sort this out by figuring
ad9e5f
+        # out the correct family and making sure a valid section exist.
ad9e5f
+        family = "ipv6" if is_ipv6_addr(dns) else "ipv4"
ad9e5f
+        self._set_default(family, "method", "disabled")
ad9e5f
+
ad9e5f
+        self._set_default(family, "dns", "")
ad9e5f
+        self.config[family]["dns"] = self.config[family]["dns"] + dns + ";"
ad9e5f
+
ad9e5f
+    def _add_dns_search(self, family, dns_search):
ad9e5f
+        """
ad9e5f
+        Extends the ipv[46].dns-search property with a name server.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        self._set_default(family, "dns-search", "")
ad9e5f
+        self.config[family]["dns-search"] = (
ad9e5f
+            self.config[family]["dns-search"] + ";".join(dns_search) + ";"
ad9e5f
+        )
ad9e5f
+
ad9e5f
+    def con_uuid(self):
ad9e5f
+        """
ad9e5f
+        Returns the connection UUID
ad9e5f
+        """
ad9e5f
+        return self.config["connection"]["uuid"]
ad9e5f
+
ad9e5f
+    def valid(self):
ad9e5f
+        """
ad9e5f
+        Can this be serialized into a meaningful connection profile?
ad9e5f
+        """
ad9e5f
+        return self.config.has_option("connection", "type")
ad9e5f
+
ad9e5f
+    @staticmethod
ad9e5f
+    def mac_addr(addr):
ad9e5f
+        """
ad9e5f
+        Sanitize a MAC address.
ad9e5f
+        """
ad9e5f
+        return addr.replace("-", ":").upper()
ad9e5f
+
ad9e5f
+    def render_interface(self, iface, renderer):
ad9e5f
+        """
ad9e5f
+        Integrate information from network state interface information
ad9e5f
+        into the connection. Most of the work is done here.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        # Initialize type & connectivity
ad9e5f
+        _type_map = {
ad9e5f
+            "physical": "ethernet",
ad9e5f
+            "vlan": "vlan",
ad9e5f
+            "bond": "bond",
ad9e5f
+            "bridge": "bridge",
ad9e5f
+            "infiniband": "infiniband",
ad9e5f
+            "loopback": None,
ad9e5f
+        }
ad9e5f
+
ad9e5f
+        if_type = _type_map[iface["type"]]
ad9e5f
+        if if_type is None:
ad9e5f
+            return
ad9e5f
+        if "bond-master" in iface:
ad9e5f
+            slave_type = "bond"
ad9e5f
+        else:
ad9e5f
+            slave_type = None
ad9e5f
+
ad9e5f
+        self.config["connection"]["type"] = if_type
ad9e5f
+        if slave_type is not None:
ad9e5f
+            self.config["connection"]["slave-type"] = slave_type
ad9e5f
+            self.config["connection"]["master"] = renderer.con_ref(
ad9e5f
+                iface[slave_type + "-master"]
ad9e5f
+            )
ad9e5f
+
ad9e5f
+        # Add type specific-section
ad9e5f
+        self.config[if_type] = {}
ad9e5f
+
ad9e5f
+        # These are the interface properties that map nicely
ad9e5f
+        # to NetworkManager properties
ad9e5f
+        _prop_map = {
ad9e5f
+            "bond": {
ad9e5f
+                "mode": "bond-mode",
ad9e5f
+                "miimon": "bond_miimon",
ad9e5f
+                "xmit_hash_policy": "bond-xmit-hash-policy",
ad9e5f
+                "num_grat_arp": "bond-num-grat-arp",
ad9e5f
+                "downdelay": "bond-downdelay",
ad9e5f
+                "updelay": "bond-updelay",
ad9e5f
+                "fail_over_mac": "bond-fail-over-mac",
ad9e5f
+                "primary_reselect": "bond-primary-reselect",
ad9e5f
+                "primary": "bond-primary",
ad9e5f
+            },
ad9e5f
+            "bridge": {
ad9e5f
+                "stp": "bridge_stp",
ad9e5f
+                "priority": "bridge_bridgeprio",
ad9e5f
+            },
ad9e5f
+            "vlan": {
ad9e5f
+                "id": "vlan_id",
ad9e5f
+            },
ad9e5f
+            "ethernet": {},
ad9e5f
+            "infiniband": {},
ad9e5f
+        }
ad9e5f
+
ad9e5f
+        device_mtu = iface["mtu"]
ad9e5f
+        ipv4_mtu = None
ad9e5f
+
ad9e5f
+        # Deal with Layer 3 configuration
ad9e5f
+        for subnet in iface["subnets"]:
ad9e5f
+            family = "ipv6" if subnet_is_ipv6(subnet) else "ipv4"
ad9e5f
+
ad9e5f
+            self._set_ip_method(family, subnet["type"])
ad9e5f
+            if "address" in subnet:
ad9e5f
+                self._add_address(family, subnet)
ad9e5f
+            if "gateway" in subnet:
ad9e5f
+                self.config[family]["gateway"] = subnet["gateway"]
ad9e5f
+            for route in subnet["routes"]:
ad9e5f
+                self._add_route(family, route)
ad9e5f
+            if "dns_nameservers" in subnet:
ad9e5f
+                for nameserver in subnet["dns_nameservers"]:
ad9e5f
+                    self._add_nameserver(nameserver)
ad9e5f
+            if "dns_search" in subnet:
ad9e5f
+                self._add_dns_search(family, subnet["dns_search"])
ad9e5f
+            if family == "ipv4" and "mtu" in subnet:
ad9e5f
+                ipv4_mtu = subnet["mtu"]
ad9e5f
+
ad9e5f
+        if ipv4_mtu is None:
ad9e5f
+            ipv4_mtu = device_mtu
ad9e5f
+        if not ipv4_mtu == device_mtu:
ad9e5f
+            LOG.warning(
ad9e5f
+                "Network config: ignoring %s device-level mtu:%s"
ad9e5f
+                " because ipv4 subnet-level mtu:%s provided.",
ad9e5f
+                iface["name"],
ad9e5f
+                device_mtu,
ad9e5f
+                ipv4_mtu,
ad9e5f
+            )
ad9e5f
+
ad9e5f
+        # Parse type-specific properties
ad9e5f
+        for nm_prop, key in _prop_map[if_type].items():
ad9e5f
+            if key not in iface:
ad9e5f
+                continue
ad9e5f
+            if iface[key] is None:
ad9e5f
+                continue
ad9e5f
+            if isinstance(iface[key], bool):
ad9e5f
+                self.config[if_type][nm_prop] = (
ad9e5f
+                    "true" if iface[key] else "false"
ad9e5f
+                )
ad9e5f
+            else:
ad9e5f
+                self.config[if_type][nm_prop] = str(iface[key])
ad9e5f
+
ad9e5f
+        # These ones need special treatment
ad9e5f
+        if if_type == "ethernet":
ad9e5f
+            if iface["wakeonlan"] is True:
ad9e5f
+                # NM_SETTING_WIRED_WAKE_ON_LAN_MAGIC
ad9e5f
+                self.config["ethernet"]["wake-on-lan"] = str(0x40)
ad9e5f
+            if ipv4_mtu is not None:
ad9e5f
+                self.config["ethernet"]["mtu"] = str(ipv4_mtu)
ad9e5f
+            if iface["mac_address"] is not None:
ad9e5f
+                self.config["ethernet"]["mac-address"] = self.mac_addr(
ad9e5f
+                    iface["mac_address"]
ad9e5f
+                )
ad9e5f
+        if if_type == "vlan" and "vlan-raw-device" in iface:
ad9e5f
+            self.config["vlan"]["parent"] = renderer.con_ref(
ad9e5f
+                iface["vlan-raw-device"]
ad9e5f
+            )
ad9e5f
+        if if_type == "bridge":
ad9e5f
+            # Bridge is ass-backwards compared to bond
ad9e5f
+            for port in iface["bridge_ports"]:
ad9e5f
+                port = renderer.get_conn(port)
ad9e5f
+                port._set_default("connection", "slave-type", "bridge")
ad9e5f
+                port._set_default("connection", "master", self.con_uuid())
ad9e5f
+            if iface["mac_address"] is not None:
ad9e5f
+                self.config["bridge"]["mac-address"] = self.mac_addr(
ad9e5f
+                    iface["mac_address"]
ad9e5f
+                )
ad9e5f
+        if if_type == "infiniband" and ipv4_mtu is not None:
ad9e5f
+            self.config["infiniband"]["transport-mode"] = "datagram"
ad9e5f
+            self.config["infiniband"]["mtu"] = str(ipv4_mtu)
ad9e5f
+            if iface["mac_address"] is not None:
ad9e5f
+                self.config["infiniband"]["mac-address"] = self.mac_addr(
ad9e5f
+                    iface["mac_address"]
ad9e5f
+                )
ad9e5f
+
ad9e5f
+        # Finish up
ad9e5f
+        if if_type == "bridge" or not self.config.has_option(
ad9e5f
+            if_type, "mac-address"
ad9e5f
+        ):
ad9e5f
+            self.config["connection"]["interface-name"] = iface["name"]
ad9e5f
+
ad9e5f
+    def dump(self):
ad9e5f
+        """
ad9e5f
+        Stringify.
ad9e5f
+        """
ad9e5f
+
ad9e5f
+        buf = io.StringIO()
ad9e5f
+        self.config.write(buf, space_around_delimiters=False)
ad9e5f
+        header = "# Generated by cloud-init. Changes will be lost.\n\n"
ad9e5f
+        return header + buf.getvalue()
ad9e5f
+
ad9e5f
+
ad9e5f
+class Renderer(renderer.Renderer):
ad9e5f
+    """Renders network information in a NetworkManager keyfile format."""
ad9e5f
+
ad9e5f
+    def __init__(self, config=None):
ad9e5f
+        self.connections = {}
ad9e5f
+
ad9e5f
+    def get_conn(self, con_id):
ad9e5f
+        return self.connections[con_id]
ad9e5f
+
ad9e5f
+    def con_ref(self, con_id):
ad9e5f
+        if con_id in self.connections:
ad9e5f
+            return self.connections[con_id].con_uuid()
ad9e5f
+        else:
ad9e5f
+            # Well, what can we do...
ad9e5f
+            return con_id
ad9e5f
+
ad9e5f
+    def render_network_state(self, network_state, templates=None, target=None):
ad9e5f
+        # First pass makes sure there's NMConnections for all known
ad9e5f
+        # interfaces that have UUIDs that can be linked to from related
ad9e5f
+        # interfaces
ad9e5f
+        for iface in network_state.iter_interfaces():
ad9e5f
+            self.connections[iface["name"]] = NMConnection(iface["name"])
ad9e5f
+
ad9e5f
+        # Now render the actual interface configuration
ad9e5f
+        for iface in network_state.iter_interfaces():
ad9e5f
+            conn = self.connections[iface["name"]]
ad9e5f
+            conn.render_interface(iface, self)
ad9e5f
+
ad9e5f
+        # And finally write the files
ad9e5f
+        for con_id, conn in self.connections.items():
ad9e5f
+            if not conn.valid():
ad9e5f
+                continue
ad9e5f
+            name = conn_filename(con_id, target)
ad9e5f
+            util.write_file(name, conn.dump(), 0o600)
ad9e5f
+
ad9e5f
+
ad9e5f
+def conn_filename(con_id, target=None):
ad9e5f
+    target_con_dir = subp.target_path(target, NM_RUN_DIR)
ad9e5f
+    con_file = f"cloud-init-{con_id}.nmconnection"
ad9e5f
+    return f"{target_con_dir}/system-connections/{con_file}"
ad9e5f
+
ad9e5f
+
ad9e5f
+def available(target=None):
ad9e5f
+    target_nm_dir = subp.target_path(target, NM_LIB_DIR)
ad9e5f
+    return os.path.exists(target_nm_dir)
ad9e5f
+
ad9e5f
+
ad9e5f
+# vi: ts=4 expandtab
ad9e5f
diff --git a/cloudinit/net/renderers.py b/cloudinit/net/renderers.py
ad9e5f
index c755f04c..7edc34b5 100644
ad9e5f
--- a/cloudinit/net/renderers.py
ad9e5f
+++ b/cloudinit/net/renderers.py
ad9e5f
@@ -8,6 +8,7 @@ from . import (
ad9e5f
     freebsd,
ad9e5f
     netbsd,
ad9e5f
     netplan,
ad9e5f
+    network_manager,
ad9e5f
     networkd,
ad9e5f
     openbsd,
ad9e5f
     renderer,
ad9e5f
@@ -19,6 +20,7 @@ NAME_TO_RENDERER = {
ad9e5f
     "freebsd": freebsd,
ad9e5f
     "netbsd": netbsd,
ad9e5f
     "netplan": netplan,
ad9e5f
+    "network-manager": network_manager,
ad9e5f
     "networkd": networkd,
ad9e5f
     "openbsd": openbsd,
ad9e5f
     "sysconfig": sysconfig,
ad9e5f
@@ -28,6 +30,7 @@ DEFAULT_PRIORITY = [
ad9e5f
     "eni",
ad9e5f
     "sysconfig",
ad9e5f
     "netplan",
ad9e5f
+    "network-manager",
ad9e5f
     "freebsd",
ad9e5f
     "netbsd",
ad9e5f
     "openbsd",
ad9e5f
diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
ad9e5f
index 362e8d19..c3b0c795 100644
ad9e5f
--- a/cloudinit/net/sysconfig.py
ad9e5f
+++ b/cloudinit/net/sysconfig.py
ad9e5f
@@ -5,8 +5,6 @@ import io
ad9e5f
 import os
ad9e5f
 import re
ad9e5f
 
ad9e5f
-from configobj import ConfigObj
ad9e5f
-
ad9e5f
 from cloudinit import log as logging
ad9e5f
 from cloudinit import subp, util
ad9e5f
 from cloudinit.distros.parsers import networkmanager_conf, resolv_conf
ad9e5f
@@ -66,24 +64,6 @@ def _quote_value(value):
ad9e5f
         return value
ad9e5f
 
ad9e5f
 
ad9e5f
-def enable_ifcfg_rh(path):
ad9e5f
-    """Add ifcfg-rh to NetworkManager.cfg plugins if main section is present"""
ad9e5f
-    config = ConfigObj(path)
ad9e5f
-    if "main" in config:
ad9e5f
-        if "plugins" in config["main"]:
ad9e5f
-            if "ifcfg-rh" in config["main"]["plugins"]:
ad9e5f
-                return
ad9e5f
-        else:
ad9e5f
-            config["main"]["plugins"] = []
ad9e5f
-
ad9e5f
-        if isinstance(config["main"]["plugins"], list):
ad9e5f
-            config["main"]["plugins"].append("ifcfg-rh")
ad9e5f
-        else:
ad9e5f
-            config["main"]["plugins"] = [config["main"]["plugins"], "ifcfg-rh"]
ad9e5f
-        config.write()
ad9e5f
-        LOG.debug("Enabled ifcfg-rh NetworkManager plugins")
ad9e5f
-
ad9e5f
-
ad9e5f
 class ConfigMap(object):
ad9e5f
     """Sysconfig like dictionary object."""
ad9e5f
 
ad9e5f
@@ -1031,8 +1011,6 @@ class Renderer(renderer.Renderer):
ad9e5f
             netrules_content = self._render_persistent_net(network_state)
ad9e5f
             netrules_path = subp.target_path(target, self.netrules_path)
ad9e5f
             util.write_file(netrules_path, netrules_content, file_mode)
ad9e5f
-        if available_nm(target=target):
ad9e5f
-            enable_ifcfg_rh(subp.target_path(target, path=NM_CFG_FILE))
ad9e5f
 
ad9e5f
         sysconfig_path = subp.target_path(target, templates.get("control"))
ad9e5f
         # Distros configuring /etc/sysconfig/network as a file e.g. Centos
ad9e5f
@@ -1071,14 +1049,9 @@ def _supported_vlan_names(rdev, vid):
ad9e5f
 
ad9e5f
 
ad9e5f
 def available(target=None):
ad9e5f
-    sysconfig = available_sysconfig(target=target)
ad9e5f
-    nm = available_nm(target=target)
ad9e5f
-    return util.system_info()["variant"] in KNOWN_DISTROS and any(
ad9e5f
-        [nm, sysconfig]
ad9e5f
-    )
ad9e5f
-
ad9e5f
+    if not util.system_info()["variant"] in KNOWN_DISTROS:
ad9e5f
+        return False
ad9e5f
 
ad9e5f
-def available_sysconfig(target=None):
ad9e5f
     expected = ["ifup", "ifdown"]
ad9e5f
     search = ["/sbin", "/usr/sbin"]
ad9e5f
     for p in expected:
ad9e5f
@@ -1095,10 +1068,4 @@ def available_sysconfig(target=None):
ad9e5f
     return False
ad9e5f
 
ad9e5f
 
ad9e5f
-def available_nm(target=None):
ad9e5f
-    if not os.path.isfile(subp.target_path(target, path=NM_CFG_FILE)):
ad9e5f
-        return False
ad9e5f
-    return True
ad9e5f
-
ad9e5f
-
ad9e5f
 # vi: ts=4 expandtab
ad9e5f
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
ad9e5f
index 591241b3..ef21ad76 100644
ad9e5f
--- a/tests/unittests/test_net.py
ad9e5f
+++ b/tests/unittests/test_net.py
ad9e5f
@@ -21,6 +21,7 @@ from cloudinit.net import (
ad9e5f
     interface_has_own_mac,
ad9e5f
     natural_sort_key,
ad9e5f
     netplan,
ad9e5f
+    network_manager,
ad9e5f
     network_state,
ad9e5f
     networkd,
ad9e5f
     renderers,
ad9e5f
@@ -611,6 +612,37 @@ dns = none
ad9e5f
                 ),
ad9e5f
             ),
ad9e5f
         ],
ad9e5f
+        "expected_network_manager": [
ad9e5f
+            (
ad9e5f
+                "".join(
ad9e5f
+                    [
ad9e5f
+                        "etc/NetworkManager/system-connections",
ad9e5f
+                        "/cloud-init-eth0.nmconnection",
ad9e5f
+                    ]
ad9e5f
+                ),
ad9e5f
+                """
ad9e5f
+# Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+[connection]
ad9e5f
+id=cloud-init eth0
ad9e5f
+uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
ad9e5f
+type=ethernet
ad9e5f
+
ad9e5f
+[user]
ad9e5f
+org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+[ethernet]
ad9e5f
+mac-address=FA:16:3E:ED:9A:59
ad9e5f
+
ad9e5f
+[ipv4]
ad9e5f
+method=manual
ad9e5f
+may-fail=false
ad9e5f
+address1=172.19.1.34/22
ad9e5f
+route1=0.0.0.0/0,172.19.3.254
ad9e5f
+
ad9e5f
+""".lstrip(),
ad9e5f
+            ),
ad9e5f
+        ],
ad9e5f
     },
ad9e5f
     {
ad9e5f
         "in_data": {
ad9e5f
@@ -1073,6 +1105,50 @@ NETWORK_CONFIGS = {
ad9e5f
                 USERCTL=no"""
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-eth1.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth1
ad9e5f
+                uuid=3c50eb47-7260-5a6d-801d-bd4f587d6b58
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=CF:D6:AF:48:E8:80
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth99.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth99
ad9e5f
+                uuid=b1b88000-1f03-5360-8377-1a2205efffb4
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=C0:D6:9F:2C:E8:80
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.21.3/24
ad9e5f
+                route1=0.0.0.0/0,65.61.151.37
ad9e5f
+                dns=8.8.8.8;8.8.4.4;
ad9e5f
+                dns-search=barley.maas;sach.maas;
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
             """
ad9e5f
             version: 1
ad9e5f
@@ -1145,6 +1221,34 @@ NETWORK_CONFIGS = {
ad9e5f
                 STARTMODE=auto"""
ad9e5f
             )
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=dhcp
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
             """\
ad9e5f
             version: 1
ad9e5f
@@ -1247,6 +1351,37 @@ NETWORK_CONFIGS = {
ad9e5f
                 """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mtu=9000
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.14.2/24
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+                address1=2001:1::1/64
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
     },
ad9e5f
     "v6_and_v4": {
ad9e5f
         "expected_sysconfig_opensuse": {
ad9e5f
@@ -1257,6 +1392,34 @@ NETWORK_CONFIGS = {
ad9e5f
                 STARTMODE=auto"""
ad9e5f
             )
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=dhcp
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
             """\
ad9e5f
             version: 1
ad9e5f
@@ -1330,6 +1493,30 @@ NETWORK_CONFIGS = {
ad9e5f
                 """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=dhcp
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
     },
ad9e5f
     "dhcpv6_accept_ra": {
ad9e5f
         "expected_eni": textwrap.dedent(
ad9e5f
@@ -1537,6 +1724,30 @@ NETWORK_CONFIGS = {
ad9e5f
             """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
     },
ad9e5f
     "static6": {
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
@@ -1625,6 +1836,30 @@ NETWORK_CONFIGS = {
ad9e5f
             """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
     },
ad9e5f
     "dhcpv6_stateful": {
ad9e5f
         "expected_eni": textwrap.dedent(
ad9e5f
@@ -1724,6 +1959,29 @@ NETWORK_CONFIGS = {
ad9e5f
             """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
         "yaml_v2": textwrap.dedent(
ad9e5f
             """\
ad9e5f
             version: 2
ad9e5f
@@ -1777,6 +2035,30 @@ NETWORK_CONFIGS = {
ad9e5f
             """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-iface0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init iface0
ad9e5f
+                uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=iface0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                wake-on-lan=64
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
         "yaml_v2": textwrap.dedent(
ad9e5f
             """\
ad9e5f
             version: 2
ad9e5f
@@ -2215,6 +2497,254 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true
ad9e5f
                 USERCTL=no"""
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-eth3.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth3
ad9e5f
+                uuid=b7e95dda-7746-5bf8-bf33-6e5f3c926790
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bridge
ad9e5f
+                master=dee46ce4-af7a-5e7c-aa08-b25533ae9213
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=66:BB:9F:2C:E8:80
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth5.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth5
ad9e5f
+                uuid=5fda13c7-9942-5e90-a41b-1d043bd725dc
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=98:BB:9F:2C:E8:8A
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-ib0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init ib0
ad9e5f
+                uuid=11a1dda7-78b4-5529-beba-d9b5f549ad7b
ad9e5f
+                type=infiniband
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [infiniband]
ad9e5f
+                transport-mode=datagram
ad9e5f
+                mtu=9000
ad9e5f
+                mac-address=A0:00:02:20:FE:80:00:00:00:00:00:00:EC:0D:9A:03:00:15:E2:C1
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.200.7/24
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-bond0.200.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init bond0.200
ad9e5f
+                uuid=88984a9c-ff22-5233-9267-86315e0acaa7
ad9e5f
+                type=vlan
ad9e5f
+                interface-name=bond0.200
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [vlan]
ad9e5f
+                id=200
ad9e5f
+                parent=54317911-f840-516b-a10d-82cb4c1f075c
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth0
ad9e5f
+                uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=C0:D6:9F:2C:E8:80
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth4.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth4
ad9e5f
+                uuid=e27e4959-fb50-5580-b9a4-2073554627b9
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bridge
ad9e5f
+                master=dee46ce4-af7a-5e7c-aa08-b25533ae9213
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=98:BB:9F:2C:E8:80
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth1.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth1
ad9e5f
+                uuid=3c50eb47-7260-5a6d-801d-bd4f587d6b58
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bond
ad9e5f
+                master=54317911-f840-516b-a10d-82cb4c1f075c
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=AA:D6:9F:2C:E8:80
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-br0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init br0
ad9e5f
+                uuid=dee46ce4-af7a-5e7c-aa08-b25533ae9213
ad9e5f
+                type=bridge
ad9e5f
+                interface-name=br0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [bridge]
ad9e5f
+                stp=false
ad9e5f
+                priority=22
ad9e5f
+                mac-address=BB:BB:BB:BB:BB:AA
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.14.2/24
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+                address1=2001:1::1/64
ad9e5f
+                route1=::/0,2001:4800:78ff:1b::1
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth0.101.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth0.101
ad9e5f
+                uuid=b5acec5e-db80-5935-8b02-0d5619fc42bf
ad9e5f
+                type=vlan
ad9e5f
+                interface-name=eth0.101
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [vlan]
ad9e5f
+                id=101
ad9e5f
+                parent=1dd9a779-d327-56e1-8454-c65e2556c12c
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.0.2/24
ad9e5f
+                gateway=192.168.0.1
ad9e5f
+                dns=192.168.0.10;10.23.23.134;
ad9e5f
+                dns-search=barley.maas;sacchromyces.maas;brettanomyces.maas;
ad9e5f
+                address2=192.168.2.10/24
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-bond0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init bond0
ad9e5f
+                uuid=54317911-f840-516b-a10d-82cb4c1f075c
ad9e5f
+                type=bond
ad9e5f
+                interface-name=bond0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [bond]
ad9e5f
+                mode=active-backup
ad9e5f
+                miimon=100
ad9e5f
+                xmit_hash_policy=layer3+4
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=dhcp
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth2.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth2
ad9e5f
+                uuid=5559a242-3421-5fdd-896e-9cb8313d5804
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bond
ad9e5f
+                master=54317911-f840-516b-a10d-82cb4c1f075c
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=C0:BB:9F:2C:E8:80
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
             """
ad9e5f
             version: 1
ad9e5f
@@ -2403,10 +2933,10 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true
ad9e5f
                   - type: static
ad9e5f
                     address: 2001:1::1/92
ad9e5f
                     routes:
ad9e5f
-                        - gateway: 2001:67c:1562:1
ad9e5f
+                        - gateway: 2001:67c:1562::1
ad9e5f
                           network: 2001:67c:1
ad9e5f
                           netmask: "ffff:ffff::"
ad9e5f
-                        - gateway: 3001:67c:1562:1
ad9e5f
+                        - gateway: 3001:67c:15::1
ad9e5f
                           network: 3001:67c:1
ad9e5f
                           netmask: "ffff:ffff::"
ad9e5f
                           metric: 10000
ad9e5f
@@ -2451,10 +2981,10 @@ pre-down route del -net 10.0.0.0/8 gw 11.0.0.1 metric 3 || true
ad9e5f
                      -   to: 10.1.3.0/24
ad9e5f
                          via: 192.168.0.3
ad9e5f
                      -   to: 2001:67c:1/32
ad9e5f
-                         via: 2001:67c:1562:1
ad9e5f
+                         via: 2001:67c:1562::1
ad9e5f
                      -   metric: 10000
ad9e5f
                          to: 3001:67c:1/32
ad9e5f
-                         via: 3001:67c:1562:1
ad9e5f
+                         via: 3001:67c:15::1
ad9e5f
         """
ad9e5f
         ),
ad9e5f
         "expected_eni": textwrap.dedent(
ad9e5f
@@ -2514,11 +3044,11 @@ iface bond0 inet static
ad9e5f
 # control-alias bond0
ad9e5f
 iface bond0 inet6 static
ad9e5f
     address 2001:1::1/92
ad9e5f
-    post-up route add -A inet6 2001:67c:1/32 gw 2001:67c:1562:1 || true
ad9e5f
-    pre-down route del -A inet6 2001:67c:1/32 gw 2001:67c:1562:1 || true
ad9e5f
-    post-up route add -A inet6 3001:67c:1/32 gw 3001:67c:1562:1 metric 10000 \
ad9e5f
+    post-up route add -A inet6 2001:67c:1/32 gw 2001:67c:1562::1 || true
ad9e5f
+    pre-down route del -A inet6 2001:67c:1/32 gw 2001:67c:1562::1 || true
ad9e5f
+    post-up route add -A inet6 3001:67c:1/32 gw 3001:67c:15::1 metric 10000 \
ad9e5f
 || true
ad9e5f
-    pre-down route del -A inet6 3001:67c:1/32 gw 3001:67c:1562:1 metric 10000 \
ad9e5f
+    pre-down route del -A inet6 3001:67c:1/32 gw 3001:67c:15::1 metric 10000 \
ad9e5f
 || true
ad9e5f
         """
ad9e5f
         ),
ad9e5f
@@ -2561,8 +3091,8 @@ iface bond0 inet6 static
ad9e5f
                 -   to: 2001:67c:1562:8007::1/64
ad9e5f
                     via: 2001:67c:1562:8007::aac:40b2
ad9e5f
                 -   metric: 10000
ad9e5f
-                    to: 3001:67c:1562:8007::1/64
ad9e5f
-                    via: 3001:67c:1562:8007::aac:40b2
ad9e5f
+                    to: 3001:67c:15:8007::1/64
ad9e5f
+                    via: 3001:67c:15:8007::aac:40b2
ad9e5f
             """
ad9e5f
         ),
ad9e5f
         "expected_netplan-v2": textwrap.dedent(
ad9e5f
@@ -2594,8 +3124,8 @@ iface bond0 inet6 static
ad9e5f
                      -   to: 2001:67c:1562:8007::1/64
ad9e5f
                          via: 2001:67c:1562:8007::aac:40b2
ad9e5f
                      -   metric: 10000
ad9e5f
-                         to: 3001:67c:1562:8007::1/64
ad9e5f
-                         via: 3001:67c:1562:8007::aac:40b2
ad9e5f
+                         to: 3001:67c:15:8007::1/64
ad9e5f
+                         via: 3001:67c:15:8007::aac:40b2
ad9e5f
              ethernets:
ad9e5f
                  eth0:
ad9e5f
                      match:
ad9e5f
@@ -2694,8 +3224,8 @@ iface bond0 inet6 static
ad9e5f
                 """\
ad9e5f
         # Created by cloud-init on instance boot automatically, do not edit.
ad9e5f
         #
ad9e5f
-        2001:67c:1/32 via 2001:67c:1562:1  dev bond0
ad9e5f
-        3001:67c:1/32 via 3001:67c:1562:1 metric 10000 dev bond0
ad9e5f
+        2001:67c:1/32 via 2001:67c:1562::1  dev bond0
ad9e5f
+        3001:67c:1/32 via 3001:67c:15::1 metric 10000 dev bond0
ad9e5f
             """
ad9e5f
             ),
ad9e5f
             "route-bond0": textwrap.dedent(
ad9e5f
@@ -2718,6 +3248,88 @@ iface bond0 inet6 static
ad9e5f
         """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-bond0s0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init bond0s0
ad9e5f
+                uuid=09d0b5b9-67e7-5577-a1af-74d1cf17a71e
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bond
ad9e5f
+                master=54317911-f840-516b-a10d-82cb4c1f075c
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=AA:BB:CC:DD:E8:00
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-bond0s1.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init bond0s1
ad9e5f
+                uuid=4d9aca96-b515-5630-ad83-d13daac7f9d0
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bond
ad9e5f
+                master=54317911-f840-516b-a10d-82cb4c1f075c
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=AA:BB:CC:DD:E8:01
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-bond0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init bond0
ad9e5f
+                uuid=54317911-f840-516b-a10d-82cb4c1f075c
ad9e5f
+                type=bond
ad9e5f
+                interface-name=bond0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [bond]
ad9e5f
+                mode=active-backup
ad9e5f
+                miimon=100
ad9e5f
+                xmit_hash_policy=layer3+4
ad9e5f
+                num_grat_arp=5
ad9e5f
+                downdelay=10
ad9e5f
+                updelay=20
ad9e5f
+                fail_over_mac=active
ad9e5f
+                primary_reselect=always
ad9e5f
+                primary=bond0s0
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.0.2/24
ad9e5f
+                gateway=192.168.0.1
ad9e5f
+                route1=10.1.3.0/24,192.168.0.3
ad9e5f
+                address2=192.168.1.2/24
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+                address1=2001:1::1/92
ad9e5f
+                route1=2001:67c:1/32,2001:67c:1562::1
ad9e5f
+                route2=3001:67c:1/32,3001:67c:15::1
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
     },
ad9e5f
     "vlan": {
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
@@ -2801,6 +3413,58 @@ iface bond0 inet6 static
ad9e5f
                 VLAN=yes"""
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-en0.99.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init en0.99
ad9e5f
+                uuid=f594e2ed-f107-51df-b225-1dc530a5356b
ad9e5f
+                type=vlan
ad9e5f
+                interface-name=en0.99
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [vlan]
ad9e5f
+                id=99
ad9e5f
+                parent=e0ca478b-8d84-52ab-8fae-628482c629b5
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.2.2/24
ad9e5f
+                address2=192.168.1.2/24
ad9e5f
+                gateway=192.168.1.1
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+                address1=2001:1::bbbb/96
ad9e5f
+                route1=::/0,2001:1::1
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-en0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init en0
ad9e5f
+                uuid=e0ca478b-8d84-52ab-8fae-628482c629b5
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=AA:BB:CC:DD:E8:00
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
     },
ad9e5f
     "bridge": {
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
@@ -2909,6 +3573,82 @@ iface bond0 inet6 static
ad9e5f
                 """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-br0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init br0
ad9e5f
+                uuid=dee46ce4-af7a-5e7c-aa08-b25533ae9213
ad9e5f
+                type=bridge
ad9e5f
+                interface-name=br0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [bridge]
ad9e5f
+                stp=false
ad9e5f
+                priority=22
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.2.2/24
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth0
ad9e5f
+                uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bridge
ad9e5f
+                master=dee46ce4-af7a-5e7c-aa08-b25533ae9213
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=52:54:00:12:34:00
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+                address1=2001:1::100/96
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth1.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth1
ad9e5f
+                uuid=3c50eb47-7260-5a6d-801d-bd4f587d6b58
ad9e5f
+                type=ethernet
ad9e5f
+                slave-type=bridge
ad9e5f
+                master=dee46ce4-af7a-5e7c-aa08-b25533ae9213
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=52:54:00:12:34:01
ad9e5f
+
ad9e5f
+                [ipv6]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                addr-gen-mode=stable-privacy
ad9e5f
+                address1=2001:1::101/96
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
     },
ad9e5f
     "manual": {
ad9e5f
         "yaml": textwrap.dedent(
ad9e5f
@@ -3037,28 +3777,95 @@ iface bond0 inet6 static
ad9e5f
                 """
ad9e5f
             ),
ad9e5f
         },
ad9e5f
-    },
ad9e5f
-}
ad9e5f
+        "expected_network_manager": {
ad9e5f
+            "cloud-init-eth0.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
 
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth0
ad9e5f
+                uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
ad9e5f
+                type=ethernet
ad9e5f
 
ad9e5f
-CONFIG_V1_EXPLICIT_LOOPBACK = {
ad9e5f
-    "version": 1,
ad9e5f
-    "config": [
ad9e5f
-        {
ad9e5f
-            "name": "eth0",
ad9e5f
-            "type": "physical",
ad9e5f
-            "subnets": [{"control": "auto", "type": "dhcp"}],
ad9e5f
-        },
ad9e5f
-        {
ad9e5f
-            "name": "lo",
ad9e5f
-            "type": "loopback",
ad9e5f
-            "subnets": [{"control": "auto", "type": "loopback"}],
ad9e5f
-        },
ad9e5f
-    ],
ad9e5f
-}
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
 
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=52:54:00:12:34:00
ad9e5f
 
ad9e5f
-CONFIG_V1_SIMPLE_SUBNET = {
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=192.168.1.2/24
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth1.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth1
ad9e5f
+                uuid=3c50eb47-7260-5a6d-801d-bd4f587d6b58
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mtu=1480
ad9e5f
+                mac-address=52:54:00:12:34:AA
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=true
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+            "cloud-init-eth2.nmconnection": textwrap.dedent(
ad9e5f
+                """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth2
ad9e5f
+                uuid=5559a242-3421-5fdd-896e-9cb8313d5804
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=52:54:00:12:34:FF
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=true
ad9e5f
+
ad9e5f
+                """
ad9e5f
+            ),
ad9e5f
+        },
ad9e5f
+    },
ad9e5f
+}
ad9e5f
+
ad9e5f
+
ad9e5f
+CONFIG_V1_EXPLICIT_LOOPBACK = {
ad9e5f
+    "version": 1,
ad9e5f
+    "config": [
ad9e5f
+        {
ad9e5f
+            "name": "eth0",
ad9e5f
+            "type": "physical",
ad9e5f
+            "subnets": [{"control": "auto", "type": "dhcp"}],
ad9e5f
+        },
ad9e5f
+        {
ad9e5f
+            "name": "lo",
ad9e5f
+            "type": "loopback",
ad9e5f
+            "subnets": [{"control": "auto", "type": "loopback"}],
ad9e5f
+        },
ad9e5f
+    ],
ad9e5f
+}
ad9e5f
+
ad9e5f
+
ad9e5f
+CONFIG_V1_SIMPLE_SUBNET = {
ad9e5f
     "version": 1,
ad9e5f
     "config": [
ad9e5f
         {
ad9e5f
@@ -3497,7 +4304,6 @@ class TestRhelSysConfigRendering(CiTestCase):
ad9e5f
 
ad9e5f
     with_logs = True
ad9e5f
 
ad9e5f
-    nm_cfg_file = "/etc/NetworkManager/NetworkManager.conf"
ad9e5f
     scripts_dir = "/etc/sysconfig/network-scripts"
ad9e5f
     header = (
ad9e5f
         "# Created by cloud-init on instance boot automatically, "
ad9e5f
@@ -4072,78 +4878,6 @@ USERCTL=no
ad9e5f
         self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
         self._assert_headers(found)
ad9e5f
 
ad9e5f
-    def test_check_ifcfg_rh(self):
ad9e5f
-        """ifcfg-rh plugin is added NetworkManager.conf if conf present."""
ad9e5f
-        render_dir = self.tmp_dir()
ad9e5f
-        nm_cfg = subp.target_path(render_dir, path=self.nm_cfg_file)
ad9e5f
-        util.ensure_dir(os.path.dirname(nm_cfg))
ad9e5f
-
ad9e5f
-        # write a template nm.conf, note plugins is a list here
ad9e5f
-        with open(nm_cfg, "w") as fh:
ad9e5f
-            fh.write("# test_check_ifcfg_rh\n[main]\nplugins=foo,bar\n")
ad9e5f
-        self.assertTrue(os.path.exists(nm_cfg))
ad9e5f
-
ad9e5f
-        # render and read
ad9e5f
-        entry = NETWORK_CONFIGS["small"]
ad9e5f
-        found = self._render_and_read(
ad9e5f
-            network_config=yaml.load(entry["yaml"]), dir=render_dir
ad9e5f
-        )
ad9e5f
-        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
-        self._assert_headers(found)
ad9e5f
-
ad9e5f
-        # check ifcfg-rh is in the 'plugins' list
ad9e5f
-        config = sysconfig.ConfigObj(nm_cfg)
ad9e5f
-        self.assertIn("ifcfg-rh", config["main"]["plugins"])
ad9e5f
-
ad9e5f
-    def test_check_ifcfg_rh_plugins_string(self):
ad9e5f
-        """ifcfg-rh plugin is append when plugins is a string."""
ad9e5f
-        render_dir = self.tmp_path("render")
ad9e5f
-        os.makedirs(render_dir)
ad9e5f
-        nm_cfg = subp.target_path(render_dir, path=self.nm_cfg_file)
ad9e5f
-        util.ensure_dir(os.path.dirname(nm_cfg))
ad9e5f
-
ad9e5f
-        # write a template nm.conf, note plugins is a value here
ad9e5f
-        util.write_file(nm_cfg, "# test_check_ifcfg_rh\n[main]\nplugins=foo\n")
ad9e5f
-
ad9e5f
-        # render and read
ad9e5f
-        entry = NETWORK_CONFIGS["small"]
ad9e5f
-        found = self._render_and_read(
ad9e5f
-            network_config=yaml.load(entry["yaml"]), dir=render_dir
ad9e5f
-        )
ad9e5f
-        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
-        self._assert_headers(found)
ad9e5f
-
ad9e5f
-        # check raw content has plugin
ad9e5f
-        nm_file_content = util.load_file(nm_cfg)
ad9e5f
-        self.assertIn("ifcfg-rh", nm_file_content)
ad9e5f
-
ad9e5f
-        # check ifcfg-rh is in the 'plugins' list
ad9e5f
-        config = sysconfig.ConfigObj(nm_cfg)
ad9e5f
-        self.assertIn("ifcfg-rh", config["main"]["plugins"])
ad9e5f
-
ad9e5f
-    def test_check_ifcfg_rh_plugins_no_plugins(self):
ad9e5f
-        """enable_ifcfg_plugin creates plugins value if missing."""
ad9e5f
-        render_dir = self.tmp_path("render")
ad9e5f
-        os.makedirs(render_dir)
ad9e5f
-        nm_cfg = subp.target_path(render_dir, path=self.nm_cfg_file)
ad9e5f
-        util.ensure_dir(os.path.dirname(nm_cfg))
ad9e5f
-
ad9e5f
-        # write a template nm.conf, note plugins is missing
ad9e5f
-        util.write_file(nm_cfg, "# test_check_ifcfg_rh\n[main]\n")
ad9e5f
-        self.assertTrue(os.path.exists(nm_cfg))
ad9e5f
-
ad9e5f
-        # render and read
ad9e5f
-        entry = NETWORK_CONFIGS["small"]
ad9e5f
-        found = self._render_and_read(
ad9e5f
-            network_config=yaml.load(entry["yaml"]), dir=render_dir
ad9e5f
-        )
ad9e5f
-        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
-        self._assert_headers(found)
ad9e5f
-
ad9e5f
-        # check ifcfg-rh is in the 'plugins' list
ad9e5f
-        config = sysconfig.ConfigObj(nm_cfg)
ad9e5f
-        self.assertIn("ifcfg-rh", config["main"]["plugins"])
ad9e5f
-
ad9e5f
     def test_netplan_dhcp_false_disable_dhcp_in_state(self):
ad9e5f
         """netplan config with dhcp[46]: False should not add dhcp in state"""
ad9e5f
         net_config = yaml.load(NETPLAN_DHCP_FALSE)
ad9e5f
@@ -4699,6 +5433,281 @@ STARTMODE=auto
ad9e5f
         self._assert_headers(found)
ad9e5f
 
ad9e5f
 
ad9e5f
+@mock.patch(
ad9e5f
+    "cloudinit.net.is_openvswitch_internal_interface",
ad9e5f
+    mock.Mock(return_value=False),
ad9e5f
+)
ad9e5f
+class TestNetworkManagerRendering(CiTestCase):
ad9e5f
+
ad9e5f
+    with_logs = True
ad9e5f
+
ad9e5f
+    scripts_dir = "/etc/NetworkManager/system-connections"
ad9e5f
+
ad9e5f
+    expected_name = "expected_network_manager"
ad9e5f
+
ad9e5f
+    def _get_renderer(self):
ad9e5f
+        return network_manager.Renderer()
ad9e5f
+
ad9e5f
+    def _render_and_read(self, network_config=None, state=None, dir=None):
ad9e5f
+        if dir is None:
ad9e5f
+            dir = self.tmp_dir()
ad9e5f
+
ad9e5f
+        if network_config:
ad9e5f
+            ns = network_state.parse_net_config_data(network_config)
ad9e5f
+        elif state:
ad9e5f
+            ns = state
ad9e5f
+        else:
ad9e5f
+            raise ValueError("Expected data or state, got neither")
ad9e5f
+
ad9e5f
+        renderer = self._get_renderer()
ad9e5f
+        renderer.render_network_state(ns, target=dir)
ad9e5f
+        return dir2dict(dir)
ad9e5f
+
ad9e5f
+    def _compare_files_to_expected(self, expected, found):
ad9e5f
+        orig_maxdiff = self.maxDiff
ad9e5f
+        expected_d = dict(
ad9e5f
+            (os.path.join(self.scripts_dir, k), v) for k, v in expected.items()
ad9e5f
+        )
ad9e5f
+
ad9e5f
+        try:
ad9e5f
+            self.maxDiff = None
ad9e5f
+            self.assertEqual(expected_d, found)
ad9e5f
+        finally:
ad9e5f
+            self.maxDiff = orig_maxdiff
ad9e5f
+
ad9e5f
+    @mock.patch("cloudinit.net.util.get_cmdline", return_value="root=myroot")
ad9e5f
+    @mock.patch("cloudinit.net.sys_dev_path")
ad9e5f
+    @mock.patch("cloudinit.net.read_sys_net")
ad9e5f
+    @mock.patch("cloudinit.net.get_devicelist")
ad9e5f
+    def test_default_generation(
ad9e5f
+        self,
ad9e5f
+        mock_get_devicelist,
ad9e5f
+        mock_read_sys_net,
ad9e5f
+        mock_sys_dev_path,
ad9e5f
+        m_get_cmdline,
ad9e5f
+    ):
ad9e5f
+        tmp_dir = self.tmp_dir()
ad9e5f
+        _setup_test(
ad9e5f
+            tmp_dir, mock_get_devicelist, mock_read_sys_net, mock_sys_dev_path
ad9e5f
+        )
ad9e5f
+
ad9e5f
+        network_cfg = net.generate_fallback_config()
ad9e5f
+        ns = network_state.parse_net_config_data(
ad9e5f
+            network_cfg, skip_broken=False
ad9e5f
+        )
ad9e5f
+
ad9e5f
+        render_dir = os.path.join(tmp_dir, "render")
ad9e5f
+        os.makedirs(render_dir)
ad9e5f
+
ad9e5f
+        renderer = self._get_renderer()
ad9e5f
+        renderer.render_network_state(ns, target=render_dir)
ad9e5f
+
ad9e5f
+        found = dir2dict(render_dir)
ad9e5f
+        self._compare_files_to_expected(
ad9e5f
+            {
ad9e5f
+                "cloud-init-eth1000.nmconnection": textwrap.dedent(
ad9e5f
+                    """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth1000
ad9e5f
+                uuid=8c517500-0c95-5308-9c8a-3092eebc44eb
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=07:1C:C6:75:A4:BE
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                """
ad9e5f
+                ),
ad9e5f
+            },
ad9e5f
+            found,
ad9e5f
+        )
ad9e5f
+
ad9e5f
+    def test_openstack_rendering_samples(self):
ad9e5f
+        for os_sample in OS_SAMPLES:
ad9e5f
+            render_dir = self.tmp_dir()
ad9e5f
+            ex_input = os_sample["in_data"]
ad9e5f
+            ex_mac_addrs = os_sample["in_macs"]
ad9e5f
+            network_cfg = openstack.convert_net_json(
ad9e5f
+                ex_input, known_macs=ex_mac_addrs
ad9e5f
+            )
ad9e5f
+            ns = network_state.parse_net_config_data(
ad9e5f
+                network_cfg, skip_broken=False
ad9e5f
+            )
ad9e5f
+            renderer = self._get_renderer()
ad9e5f
+            # render a multiple times to simulate reboots
ad9e5f
+            renderer.render_network_state(ns, target=render_dir)
ad9e5f
+            renderer.render_network_state(ns, target=render_dir)
ad9e5f
+            renderer.render_network_state(ns, target=render_dir)
ad9e5f
+            for fn, expected_content in os_sample.get(self.expected_name, []):
ad9e5f
+                with open(os.path.join(render_dir, fn)) as fh:
ad9e5f
+                    self.assertEqual(expected_content, fh.read())
ad9e5f
+
ad9e5f
+    def test_network_config_v1_samples(self):
ad9e5f
+        ns = network_state.parse_net_config_data(CONFIG_V1_SIMPLE_SUBNET)
ad9e5f
+        render_dir = self.tmp_path("render")
ad9e5f
+        os.makedirs(render_dir)
ad9e5f
+        renderer = self._get_renderer()
ad9e5f
+        renderer.render_network_state(ns, target=render_dir)
ad9e5f
+        found = dir2dict(render_dir)
ad9e5f
+        self._compare_files_to_expected(
ad9e5f
+            {
ad9e5f
+                "cloud-init-interface0.nmconnection": textwrap.dedent(
ad9e5f
+                    """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init interface0
ad9e5f
+                uuid=8b6862ed-dbd6-5830-93f7-a91451c13828
ad9e5f
+                type=ethernet
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+                mac-address=52:54:00:12:34:00
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=manual
ad9e5f
+                may-fail=false
ad9e5f
+                address1=10.0.2.15/24
ad9e5f
+                gateway=10.0.2.2
ad9e5f
+
ad9e5f
+                """
ad9e5f
+                ),
ad9e5f
+            },
ad9e5f
+            found,
ad9e5f
+        )
ad9e5f
+
ad9e5f
+    def test_config_with_explicit_loopback(self):
ad9e5f
+        render_dir = self.tmp_path("render")
ad9e5f
+        os.makedirs(render_dir)
ad9e5f
+        ns = network_state.parse_net_config_data(CONFIG_V1_EXPLICIT_LOOPBACK)
ad9e5f
+        renderer = self._get_renderer()
ad9e5f
+        renderer.render_network_state(ns, target=render_dir)
ad9e5f
+        found = dir2dict(render_dir)
ad9e5f
+        self._compare_files_to_expected(
ad9e5f
+            {
ad9e5f
+                "cloud-init-eth0.nmconnection": textwrap.dedent(
ad9e5f
+                    """\
ad9e5f
+                # Generated by cloud-init. Changes will be lost.
ad9e5f
+
ad9e5f
+                [connection]
ad9e5f
+                id=cloud-init eth0
ad9e5f
+                uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
ad9e5f
+                type=ethernet
ad9e5f
+                interface-name=eth0
ad9e5f
+
ad9e5f
+                [user]
ad9e5f
+                org.freedesktop.NetworkManager.origin=cloud-init
ad9e5f
+
ad9e5f
+                [ethernet]
ad9e5f
+
ad9e5f
+                [ipv4]
ad9e5f
+                method=auto
ad9e5f
+                may-fail=false
ad9e5f
+
ad9e5f
+                """
ad9e5f
+                ),
ad9e5f
+            },
ad9e5f
+            found,
ad9e5f
+        )
ad9e5f
+
ad9e5f
+    def test_bond_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["bond"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_vlan_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["vlan"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_bridge_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["bridge"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_manual_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["manual"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_all_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["all"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+        self.assertNotIn(
ad9e5f
+            "WARNING: Network config: ignoring eth0.101 device-level mtu",
ad9e5f
+            self.logs.getvalue(),
ad9e5f
+        )
ad9e5f
+
ad9e5f
+    def test_small_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["small"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_v4_and_v6_static_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["v4_and_v6_static"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+        expected_msg = (
ad9e5f
+            "WARNING: Network config: ignoring iface0 device-level mtu:8999"
ad9e5f
+            " because ipv4 subnet-level mtu:9000 provided."
ad9e5f
+        )
ad9e5f
+        self.assertIn(expected_msg, self.logs.getvalue())
ad9e5f
+
ad9e5f
+    def test_dhcpv6_only_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["dhcpv6_only"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_simple_render_ipv6_slaac(self):
ad9e5f
+        entry = NETWORK_CONFIGS["ipv6_slaac"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_dhcpv6_stateless_config(self):
ad9e5f
+        entry = NETWORK_CONFIGS["dhcpv6_stateless"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_wakeonlan_disabled_config_v2(self):
ad9e5f
+        entry = NETWORK_CONFIGS["wakeonlan_disabled"]
ad9e5f
+        found = self._render_and_read(
ad9e5f
+            network_config=yaml.load(entry["yaml_v2"])
ad9e5f
+        )
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_wakeonlan_enabled_config_v2(self):
ad9e5f
+        entry = NETWORK_CONFIGS["wakeonlan_enabled"]
ad9e5f
+        found = self._render_and_read(
ad9e5f
+            network_config=yaml.load(entry["yaml_v2"])
ad9e5f
+        )
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_render_v4_and_v6(self):
ad9e5f
+        entry = NETWORK_CONFIGS["v4_and_v6"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+    def test_render_v6_and_v4(self):
ad9e5f
+        entry = NETWORK_CONFIGS["v6_and_v4"]
ad9e5f
+        found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
ad9e5f
+        self._compare_files_to_expected(entry[self.expected_name], found)
ad9e5f
+
ad9e5f
+
ad9e5f
+@mock.patch(
ad9e5f
+    "cloudinit.net.is_openvswitch_internal_interface",
ad9e5f
+    mock.Mock(return_value=False),
ad9e5f
+)
ad9e5f
 class TestEniNetRendering(CiTestCase):
ad9e5f
     @mock.patch("cloudinit.net.util.get_cmdline", return_value="root=myroot")
ad9e5f
     @mock.patch("cloudinit.net.sys_dev_path")
ad9e5f
@@ -6136,9 +7145,9 @@ class TestNetworkdRoundTrip(CiTestCase):
ad9e5f
 
ad9e5f
 class TestRenderersSelect:
ad9e5f
     @pytest.mark.parametrize(
ad9e5f
-        "renderer_selected,netplan,eni,nm,scfg,sys,networkd",
ad9e5f
+        "renderer_selected,netplan,eni,sys,network_manager,networkd",
ad9e5f
         (
ad9e5f
-            # -netplan -ifupdown -nm -scfg -sys raises error
ad9e5f
+            # -netplan -ifupdown -sys -network-manager -networkd raises error
ad9e5f
             (
ad9e5f
                 net.RendererNotFoundError,
ad9e5f
                 False,
ad9e5f
@@ -6146,52 +7155,51 @@ class TestRenderersSelect:
ad9e5f
                 False,
ad9e5f
                 False,
ad9e5f
                 False,
ad9e5f
-                False,
ad9e5f
             ),
ad9e5f
-            # -netplan +ifupdown -nm -scfg -sys selects eni
ad9e5f
-            ("eni", False, True, False, False, False, False),
ad9e5f
-            # +netplan +ifupdown -nm -scfg -sys selects eni
ad9e5f
-            ("eni", True, True, False, False, False, False),
ad9e5f
-            # +netplan -ifupdown -nm -scfg -sys selects netplan
ad9e5f
-            ("netplan", True, False, False, False, False, False),
ad9e5f
-            # Ubuntu with Network-Manager installed
ad9e5f
-            # +netplan -ifupdown +nm -scfg -sys selects netplan
ad9e5f
-            ("netplan", True, False, True, False, False, False),
ad9e5f
-            # Centos/OpenSuse with Network-Manager installed selects sysconfig
ad9e5f
-            # -netplan -ifupdown +nm -scfg +sys selects netplan
ad9e5f
-            ("sysconfig", False, False, True, False, True, False),
ad9e5f
-            # -netplan -ifupdown -nm -scfg -sys +networkd selects networkd
ad9e5f
-            ("networkd", False, False, False, False, False, True),
ad9e5f
+            # -netplan +ifupdown -sys -nm -networkd selects eni
ad9e5f
+            ("eni", False, True, False, False, False),
ad9e5f
+            # +netplan +ifupdown -sys -nm -networkd selects eni
ad9e5f
+            ("eni", True, True, False, False, False),
ad9e5f
+            # +netplan -ifupdown -sys -nm -networkd selects netplan
ad9e5f
+            ("netplan", True, False, False, False, False),
ad9e5f
+            # +netplan -ifupdown -sys -nm -networkd selects netplan
ad9e5f
+            ("netplan", True, False, False, False, False),
ad9e5f
+            # -netplan -ifupdown +sys -nm -networkd selects sysconfig
ad9e5f
+            ("sysconfig", False, False, True, False, False),
ad9e5f
+            # -netplan -ifupdown +sys +nm -networkd selects sysconfig
ad9e5f
+            ("sysconfig", False, False, True, True, False),
ad9e5f
+            # -netplan -ifupdown -sys +nm -networkd selects nm
ad9e5f
+            ("network-manager", False, False, False, True, False),
ad9e5f
+            # -netplan -ifupdown -sys +nm +networkd selects nm
ad9e5f
+            ("network-manager", False, False, False, True, True),
ad9e5f
+            # -netplan -ifupdown -sys -nm +networkd selects networkd
ad9e5f
+            ("networkd", False, False, False, False, True),
ad9e5f
         ),
ad9e5f
     )
ad9e5f
     @mock.patch("cloudinit.net.renderers.networkd.available")
ad9e5f
+    @mock.patch("cloudinit.net.renderers.network_manager.available")
ad9e5f
     @mock.patch("cloudinit.net.renderers.netplan.available")
ad9e5f
     @mock.patch("cloudinit.net.renderers.sysconfig.available")
ad9e5f
-    @mock.patch("cloudinit.net.renderers.sysconfig.available_sysconfig")
ad9e5f
-    @mock.patch("cloudinit.net.renderers.sysconfig.available_nm")
ad9e5f
     @mock.patch("cloudinit.net.renderers.eni.available")
ad9e5f
     def test_valid_renderer_from_defaults_depending_on_availability(
ad9e5f
         self,
ad9e5f
         m_eni_avail,
ad9e5f
-        m_nm_avail,
ad9e5f
-        m_scfg_avail,
ad9e5f
         m_sys_avail,
ad9e5f
         m_netplan_avail,
ad9e5f
+        m_network_manager_avail,
ad9e5f
         m_networkd_avail,
ad9e5f
         renderer_selected,
ad9e5f
         netplan,
ad9e5f
         eni,
ad9e5f
-        nm,
ad9e5f
-        scfg,
ad9e5f
         sys,
ad9e5f
+        network_manager,
ad9e5f
         networkd,
ad9e5f
     ):
ad9e5f
         """Assert proper renderer per DEFAULT_PRIORITY given availability."""
ad9e5f
         m_eni_avail.return_value = eni  # ifupdown pkg presence
ad9e5f
-        m_nm_avail.return_value = nm  # network-manager presence
ad9e5f
-        m_scfg_avail.return_value = scfg  # sysconfig presence
ad9e5f
         m_sys_avail.return_value = sys  # sysconfig/ifup/down presence
ad9e5f
         m_netplan_avail.return_value = netplan  # netplan presence
ad9e5f
+        m_network_manager_avail.return_value = network_manager  # NM presence
ad9e5f
         m_networkd_avail.return_value = networkd  # networkd presence
ad9e5f
         if isinstance(renderer_selected, str):
ad9e5f
             (renderer_name, _rnd_class) = renderers.select(
ad9e5f
@@ -6249,7 +7257,7 @@ class TestNetRenderers(CiTestCase):
ad9e5f
             priority=["sysconfig", "eni"],
ad9e5f
         )
ad9e5f
 
ad9e5f
-    @mock.patch("cloudinit.net.sysconfig.available_sysconfig")
ad9e5f
+    @mock.patch("cloudinit.net.sysconfig.available")
ad9e5f
     @mock.patch("cloudinit.util.system_info")
ad9e5f
     def test_sysconfig_available_uses_variant_mapping(self, m_info, m_avail):
ad9e5f
         m_avail.return_value = True
ad9e5f
diff --git a/tests/unittests/test_net_activators.py b/tests/unittests/test_net_activators.py
ad9e5f
index 3c29e2f7..4525c49c 100644
ad9e5f
--- a/tests/unittests/test_net_activators.py
ad9e5f
+++ b/tests/unittests/test_net_activators.py
ad9e5f
@@ -41,18 +41,20 @@ NETPLAN_CALL_LIST = [
ad9e5f
 
ad9e5f
 @pytest.fixture
ad9e5f
 def available_mocks():
ad9e5f
-    mocks = namedtuple("Mocks", "m_which, m_file")
ad9e5f
+    mocks = namedtuple("Mocks", "m_which, m_file, m_exists")
ad9e5f
     with patch("cloudinit.subp.which", return_value=True) as m_which:
ad9e5f
         with patch("os.path.isfile", return_value=True) as m_file:
ad9e5f
-            yield mocks(m_which, m_file)
ad9e5f
+            with patch("os.path.exists", return_value=True) as m_exists:
ad9e5f
+                yield mocks(m_which, m_file, m_exists)
ad9e5f
 
ad9e5f
 
ad9e5f
 @pytest.fixture
ad9e5f
 def unavailable_mocks():
ad9e5f
-    mocks = namedtuple("Mocks", "m_which, m_file")
ad9e5f
+    mocks = namedtuple("Mocks", "m_which, m_file, m_exists")
ad9e5f
     with patch("cloudinit.subp.which", return_value=False) as m_which:
ad9e5f
         with patch("os.path.isfile", return_value=False) as m_file:
ad9e5f
-            yield mocks(m_which, m_file)
ad9e5f
+            with patch("os.path.exists", return_value=False) as m_exists:
ad9e5f
+                yield mocks(m_which, m_file, m_exists)
ad9e5f
 
ad9e5f
 
ad9e5f
 class TestSearchAndSelect:
ad9e5f
@@ -113,10 +115,6 @@ NETPLAN_AVAILABLE_CALLS = [
ad9e5f
     (("netplan",), {"search": ["/usr/sbin", "/sbin"], "target": None}),
ad9e5f
 ]
ad9e5f
 
ad9e5f
-NETWORK_MANAGER_AVAILABLE_CALLS = [
ad9e5f
-    (("nmcli",), {"target": None}),
ad9e5f
-]
ad9e5f
-
ad9e5f
 NETWORKD_AVAILABLE_CALLS = [
ad9e5f
     (("ip",), {"search": ["/usr/sbin", "/bin"], "target": None}),
ad9e5f
     (("systemctl",), {"search": ["/usr/sbin", "/bin"], "target": None}),
ad9e5f
@@ -128,7 +126,6 @@ NETWORKD_AVAILABLE_CALLS = [
ad9e5f
     [
ad9e5f
         (IfUpDownActivator, IF_UP_DOWN_AVAILABLE_CALLS),
ad9e5f
         (NetplanActivator, NETPLAN_AVAILABLE_CALLS),
ad9e5f
-        (NetworkManagerActivator, NETWORK_MANAGER_AVAILABLE_CALLS),
ad9e5f
         (NetworkdActivator, NETWORKD_AVAILABLE_CALLS),
ad9e5f
     ],
ad9e5f
 )
ad9e5f
@@ -144,8 +141,72 @@ IF_UP_DOWN_BRING_UP_CALL_LIST = [
ad9e5f
 ]
ad9e5f
 
ad9e5f
 NETWORK_MANAGER_BRING_UP_CALL_LIST = [
ad9e5f
-    ((["nmcli", "connection", "up", "ifname", "eth0"],), {}),
ad9e5f
-    ((["nmcli", "connection", "up", "ifname", "eth1"],), {}),
ad9e5f
+    (
ad9e5f
+        (
ad9e5f
+            [
ad9e5f
+                "nmcli",
ad9e5f
+                "connection",
ad9e5f
+                "load",
ad9e5f
+                "".join(
ad9e5f
+                    [
ad9e5f
+                        "/etc/NetworkManager/system-connections",
ad9e5f
+                        "/cloud-init-eth0.nmconnection",
ad9e5f
+                    ]
ad9e5f
+                ),
ad9e5f
+            ],
ad9e5f
+        ),
ad9e5f
+        {},
ad9e5f
+    ),
ad9e5f
+    (
ad9e5f
+        (
ad9e5f
+            [
ad9e5f
+                "nmcli",
ad9e5f
+                "connection",
ad9e5f
+                "up",
ad9e5f
+                "filename",
ad9e5f
+                "".join(
ad9e5f
+                    [
ad9e5f
+                        "/etc/NetworkManager/system-connections",
ad9e5f
+                        "/cloud-init-eth0.nmconnection",
ad9e5f
+                    ]
ad9e5f
+                ),
ad9e5f
+            ],
ad9e5f
+        ),
ad9e5f
+        {},
ad9e5f
+    ),
ad9e5f
+    (
ad9e5f
+        (
ad9e5f
+            [
ad9e5f
+                "nmcli",
ad9e5f
+                "connection",
ad9e5f
+                "load",
ad9e5f
+                "".join(
ad9e5f
+                    [
ad9e5f
+                        "/etc/NetworkManager/system-connections",
ad9e5f
+                        "/cloud-init-eth1.nmconnection",
ad9e5f
+                    ]
ad9e5f
+                ),
ad9e5f
+            ],
ad9e5f
+        ),
ad9e5f
+        {},
ad9e5f
+    ),
ad9e5f
+    (
ad9e5f
+        (
ad9e5f
+            [
ad9e5f
+                "nmcli",
ad9e5f
+                "connection",
ad9e5f
+                "up",
ad9e5f
+                "filename",
ad9e5f
+                "".join(
ad9e5f
+                    [
ad9e5f
+                        "/etc/NetworkManager/system-connections",
ad9e5f
+                        "/cloud-init-eth1.nmconnection",
ad9e5f
+                    ]
ad9e5f
+                ),
ad9e5f
+            ],
ad9e5f
+        ),
ad9e5f
+        {},
ad9e5f
+    ),
ad9e5f
 ]
ad9e5f
 
ad9e5f
 NETWORKD_BRING_UP_CALL_LIST = [
ad9e5f
@@ -169,9 +230,11 @@ class TestActivatorsBringUp:
ad9e5f
     def test_bring_up_interface(
ad9e5f
         self, m_subp, activator, expected_call_list, available_mocks
ad9e5f
     ):
ad9e5f
+        index = 0
ad9e5f
         activator.bring_up_interface("eth0")
ad9e5f
-        assert len(m_subp.call_args_list) == 1
ad9e5f
-        assert m_subp.call_args_list[0] == expected_call_list[0]
ad9e5f
+        for call in m_subp.call_args_list:
ad9e5f
+            assert call == expected_call_list[index]
ad9e5f
+            index += 1
ad9e5f
 
ad9e5f
     @patch("cloudinit.subp.subp", return_value=("", ""))
ad9e5f
     def test_bring_up_interfaces(
ad9e5f
@@ -208,8 +271,8 @@ IF_UP_DOWN_BRING_DOWN_CALL_LIST = [
ad9e5f
 ]
ad9e5f
 
ad9e5f
 NETWORK_MANAGER_BRING_DOWN_CALL_LIST = [
ad9e5f
-    ((["nmcli", "connection", "down", "eth0"],), {}),
ad9e5f
-    ((["nmcli", "connection", "down", "eth1"],), {}),
ad9e5f
+    ((["nmcli", "device", "disconnect", "eth0"],), {}),
ad9e5f
+    ((["nmcli", "device", "disconnect", "eth1"],), {}),
ad9e5f
 ]
ad9e5f
 
ad9e5f
 NETWORKD_BRING_DOWN_CALL_LIST = [
ad9e5f
-- 
ad9e5f
2.35.3
ad9e5f