cloud-init/SOURCES/0012-Revert-Revert-Add-nati...

1392 lines
48 KiB
Diff

From 5822f72230a58d18dae8c3b76c02d4bf7a149e56 Mon Sep 17 00:00:00 2001
From: Ani Sinha <anisinha@redhat.com>
Date: Thu, 4 May 2023 15:39:17 +0530
Subject: [PATCH] Revert "Revert "Add native NetworkManager support (#1224)""
This reverts commit b1dd14ffafad2d2ca84326c525962b2ca086b292.
This is patch 2 of the two patches that re-enables NM renderer. This change can
be ignored while rebasing to latest upstream.
X-downstream-only: true
Signed-off-by: Ani Sinha <anisinha@redhat.com>
---
cloudinit/cmd/devel/net_convert.py | 14 +-
cloudinit/net/activators.py | 25 +-
cloudinit/net/network_manager.py | 393 ++++++++++++++++
cloudinit/net/renderers.py | 2 +
cloudinit/net/sysconfig.py | 42 +-
tests/unittests/test_net.py | 597 ++++++++++++++++++++-----
tests/unittests/test_net_activators.py | 11 +-
7 files changed, 923 insertions(+), 161 deletions(-)
create mode 100644 cloudinit/net/network_manager.py
diff --git a/cloudinit/cmd/devel/net_convert.py b/cloudinit/cmd/devel/net_convert.py
index 1a0a31ac..eee49860 100755
--- a/cloudinit/cmd/devel/net_convert.py
+++ b/cloudinit/cmd/devel/net_convert.py
@@ -10,7 +10,14 @@ import sys
import yaml
from cloudinit import distros, log, safeyaml
-from cloudinit.net import eni, netplan, network_state, networkd, sysconfig
+from cloudinit.net import (
+ eni,
+ netplan,
+ network_manager,
+ network_state,
+ networkd,
+ sysconfig,
+)
from cloudinit.sources import DataSourceAzure as azure
from cloudinit.sources.helpers import openstack
from cloudinit.sources.helpers.vmware.imc import guestcust_util
@@ -77,7 +84,7 @@ def get_parser(parser=None):
parser.add_argument(
"-O",
"--output-kind",
- choices=["eni", "netplan", "networkd", "sysconfig"],
+ choices=["eni", "netplan", "networkd", "sysconfig", "network-manager"],
required=True,
help="The network config format to emit",
)
@@ -150,6 +157,9 @@ def handle_args(name, args):
elif args.output_kind == "sysconfig":
r_cls = sysconfig.Renderer
config = distro.renderer_configs.get("sysconfig")
+ elif args.output_kind == "network-manager":
+ r_cls = network_manager.Renderer
+ config = distro.renderer_configs.get("network-manager")
else:
raise RuntimeError("Invalid output_kind")
diff --git a/cloudinit/net/activators.py b/cloudinit/net/activators.py
index d9a8c4d7..7d11a02c 100644
--- a/cloudinit/net/activators.py
+++ b/cloudinit/net/activators.py
@@ -1,15 +1,14 @@
# This file is part of cloud-init. See LICENSE file for license information.
import logging
-import os
from abc import ABC, abstractmethod
from typing import Dict, Iterable, List, Optional, Type, Union
from cloudinit import subp, util
from cloudinit.net.eni import available as eni_available
from cloudinit.net.netplan import available as netplan_available
+from cloudinit.net.network_manager import available as nm_available
from cloudinit.net.network_state import NetworkState
from cloudinit.net.networkd import available as networkd_available
-from cloudinit.net.sysconfig import NM_CFG_FILE
LOG = logging.getLogger(__name__)
@@ -124,20 +123,24 @@ class IfUpDownActivator(NetworkActivator):
class NetworkManagerActivator(NetworkActivator):
@staticmethod
def available(target=None) -> bool:
- """Return true if network manager can be used on this system."""
- config_present = os.path.isfile(
- subp.target_path(target, path=NM_CFG_FILE)
- )
- nmcli_present = subp.which("nmcli", target=target)
- return config_present and bool(nmcli_present)
+ """Return true if NetworkManager can be used on this system."""
+ return nm_available(target=target)
@staticmethod
def bring_up_interface(device_name: str) -> bool:
- """Bring up interface using nmcli.
+ """Bring up connection using nmcli.
Return True is successful, otherwise return False
"""
- cmd = ["nmcli", "connection", "up", "ifname", device_name]
+ from cloudinit.net.network_manager import conn_filename
+
+ filename = conn_filename(device_name)
+ cmd = ["nmcli", "connection", "load", filename]
+ if _alter_interface(cmd, device_name):
+ cmd = ["nmcli", "connection", "up", "filename", filename]
+ else:
+ _alter_interface(["nmcli", "connection", "reload"], device_name)
+ cmd = ["nmcli", "connection", "up", "ifname", device_name]
return _alter_interface(cmd, device_name)
@staticmethod
@@ -146,7 +149,7 @@ class NetworkManagerActivator(NetworkActivator):
Return True is successful, otherwise return False
"""
- cmd = ["nmcli", "connection", "down", device_name]
+ cmd = ["nmcli", "device", "disconnect", device_name]
return _alter_interface(cmd, device_name)
diff --git a/cloudinit/net/network_manager.py b/cloudinit/net/network_manager.py
new file mode 100644
index 00000000..53763d15
--- /dev/null
+++ b/cloudinit/net/network_manager.py
@@ -0,0 +1,393 @@
+# Copyright 2022 Red Hat, Inc.
+#
+# Author: Lubomir Rintel <lkundrak@v3.sk>
+# Fixes and suggestions contributed by James Falcon, Neal Gompa,
+# Zbigniew Jędrzejewski-Szmek and Emanuele Giuseppe Esposito.
+#
+# This file is part of cloud-init. See LICENSE file for license information.
+
+import configparser
+import io
+import itertools
+import os
+import uuid
+from typing import Optional
+
+from cloudinit import log as logging
+from cloudinit import subp, util
+from cloudinit.net import is_ipv6_address, renderer, subnet_is_ipv6
+from cloudinit.net.network_state import NetworkState
+
+NM_RUN_DIR = "/etc/NetworkManager"
+NM_LIB_DIR = "/usr/lib/NetworkManager"
+NM_CFG_FILE = "/etc/NetworkManager/NetworkManager.conf"
+LOG = logging.getLogger(__name__)
+
+
+class NMConnection:
+ """Represents a NetworkManager connection profile."""
+
+ def __init__(self, con_id):
+ """
+ Initializes the connection with some very basic properties,
+ notably the UUID so that the connection can be referred to.
+ """
+
+ # Chosen by fair dice roll
+ CI_NM_UUID = uuid.UUID("a3924cb8-09e0-43e9-890b-77972a800108")
+
+ self.config = configparser.ConfigParser()
+ # Identity option name mapping, to achieve case sensitivity
+ self.config.optionxform = str
+
+ self.config["connection"] = {
+ "id": f"cloud-init {con_id}",
+ "uuid": str(uuid.uuid5(CI_NM_UUID, con_id)),
+ }
+
+ # This is not actually used anywhere, but may be useful in future
+ self.config["user"] = {
+ "org.freedesktop.NetworkManager.origin": "cloud-init"
+ }
+
+ def _set_default(self, section, option, value):
+ """
+ Sets a property unless it's already set, ensuring the section
+ exists.
+ """
+
+ if not self.config.has_section(section):
+ self.config[section] = {}
+ if not self.config.has_option(section, option):
+ self.config[section][option] = value
+
+ def _set_ip_method(self, family, subnet_type):
+ """
+ Ensures there's appropriate [ipv4]/[ipv6] for given family
+ appropriate for given configuration type
+ """
+
+ method_map = {
+ "static": "manual",
+ "dhcp6": "auto",
+ "ipv6_slaac": "auto",
+ "ipv6_dhcpv6-stateless": "auto",
+ "ipv6_dhcpv6-stateful": "auto",
+ "dhcp4": "auto",
+ "dhcp": "auto",
+ }
+
+ # Ensure we got an [ipvX] section
+ self._set_default(family, "method", "disabled")
+
+ try:
+ method = method_map[subnet_type]
+ except KeyError:
+ # What else can we do
+ method = "auto"
+ self.config[family]["may-fail"] = "true"
+
+ # Make sure we don't "downgrade" the method in case
+ # we got conflicting subnets (e.g. static along with dhcp)
+ if self.config[family]["method"] == "dhcp":
+ return
+ if self.config[family]["method"] == "auto" and method == "manual":
+ return
+
+ self.config[family]["method"] = method
+ self._set_default(family, "may-fail", "false")
+
+ def _add_numbered(self, section, key_prefix, value):
+ """
+ Adds a numbered property, such as address<n> or route<n>, ensuring
+ the appropriate value gets used for <n>.
+ """
+
+ for index in itertools.count(1):
+ key = f"{key_prefix}{index}"
+ if not self.config.has_option(section, key):
+ self.config[section][key] = value
+ break
+
+ def _add_address(self, family, subnet):
+ """
+ Adds an ipv[46]address<n> property.
+ """
+
+ value = subnet["address"] + "/" + str(subnet["prefix"])
+ self._add_numbered(family, "address", value)
+
+ def _add_route(self, family, route):
+ """
+ Adds a ipv[46].route<n> property.
+ """
+
+ value = route["network"] + "/" + str(route["prefix"])
+ if "gateway" in route:
+ value = value + "," + route["gateway"]
+ self._add_numbered(family, "route", value)
+
+ def _add_nameserver(self, dns):
+ """
+ Extends the ipv[46].dns property with a name server.
+ """
+
+ # FIXME: the subnet contains IPv4 and IPv6 name server mixed
+ # together. We might be getting an IPv6 name server while
+ # we're dealing with an IPv4 subnet. Sort this out by figuring
+ # out the correct family and making sure a valid section exist.
+ family = "ipv6" if is_ipv6_address(dns) else "ipv4"
+ self._set_default(family, "method", "disabled")
+
+ self._set_default(family, "dns", "")
+ self.config[family]["dns"] = self.config[family]["dns"] + dns + ";"
+
+ def _add_dns_search(self, family, dns_search):
+ """
+ Extends the ipv[46].dns-search property with a name server.
+ """
+
+ self._set_default(family, "dns-search", "")
+ self.config[family]["dns-search"] = (
+ self.config[family]["dns-search"] + ";".join(dns_search) + ";"
+ )
+
+ def con_uuid(self):
+ """
+ Returns the connection UUID
+ """
+ return self.config["connection"]["uuid"]
+
+ def valid(self):
+ """
+ Can this be serialized into a meaningful connection profile?
+ """
+ return self.config.has_option("connection", "type")
+
+ @staticmethod
+ def mac_addr(addr):
+ """
+ Sanitize a MAC address.
+ """
+ return addr.replace("-", ":").upper()
+
+ def render_interface(self, iface, renderer):
+ """
+ Integrate information from network state interface information
+ into the connection. Most of the work is done here.
+ """
+
+ # Initialize type & connectivity
+ _type_map = {
+ "physical": "ethernet",
+ "vlan": "vlan",
+ "bond": "bond",
+ "bridge": "bridge",
+ "infiniband": "infiniband",
+ "loopback": None,
+ }
+
+ if_type = _type_map[iface["type"]]
+ if if_type is None:
+ return
+ if "bond-master" in iface:
+ slave_type = "bond"
+ else:
+ slave_type = None
+
+ self.config["connection"]["type"] = if_type
+ if slave_type is not None:
+ self.config["connection"]["slave-type"] = slave_type
+ self.config["connection"]["master"] = renderer.con_ref(
+ iface[slave_type + "-master"]
+ )
+
+ # Add type specific-section
+ self.config[if_type] = {}
+
+ # These are the interface properties that map nicely
+ # to NetworkManager properties
+ _prop_map = {
+ "bond": {
+ "mode": "bond-mode",
+ "miimon": "bond_miimon",
+ "xmit_hash_policy": "bond-xmit-hash-policy",
+ "num_grat_arp": "bond-num-grat-arp",
+ "downdelay": "bond-downdelay",
+ "updelay": "bond-updelay",
+ "fail_over_mac": "bond-fail-over-mac",
+ "primary_reselect": "bond-primary-reselect",
+ "primary": "bond-primary",
+ },
+ "bridge": {
+ "stp": "bridge_stp",
+ "priority": "bridge_bridgeprio",
+ },
+ "vlan": {
+ "id": "vlan_id",
+ },
+ "ethernet": {},
+ "infiniband": {},
+ }
+
+ device_mtu = iface["mtu"]
+ ipv4_mtu = None
+
+ # Deal with Layer 3 configuration
+ for subnet in iface["subnets"]:
+ family = "ipv6" if subnet_is_ipv6(subnet) else "ipv4"
+
+ self._set_ip_method(family, subnet["type"])
+ if "address" in subnet:
+ self._add_address(family, subnet)
+ if "gateway" in subnet:
+ self.config[family]["gateway"] = subnet["gateway"]
+ for route in subnet["routes"]:
+ self._add_route(family, route)
+ if "dns_nameservers" in subnet:
+ for nameserver in subnet["dns_nameservers"]:
+ self._add_nameserver(nameserver)
+ if "dns_search" in subnet:
+ self._add_dns_search(family, subnet["dns_search"])
+ if family == "ipv4" and "mtu" in subnet:
+ ipv4_mtu = subnet["mtu"]
+
+ if ipv4_mtu is None:
+ ipv4_mtu = device_mtu
+ if not ipv4_mtu == device_mtu:
+ LOG.warning(
+ "Network config: ignoring %s device-level mtu:%s"
+ " because ipv4 subnet-level mtu:%s provided.",
+ iface["name"],
+ device_mtu,
+ ipv4_mtu,
+ )
+
+ # Parse type-specific properties
+ for nm_prop, key in _prop_map[if_type].items():
+ if key not in iface:
+ continue
+ if iface[key] is None:
+ continue
+ if isinstance(iface[key], bool):
+ self.config[if_type][nm_prop] = (
+ "true" if iface[key] else "false"
+ )
+ else:
+ self.config[if_type][nm_prop] = str(iface[key])
+
+ # These ones need special treatment
+ if if_type == "ethernet":
+ if iface["wakeonlan"] is True:
+ # NM_SETTING_WIRED_WAKE_ON_LAN_MAGIC
+ self.config["ethernet"]["wake-on-lan"] = str(0x40)
+ if ipv4_mtu is not None:
+ self.config["ethernet"]["mtu"] = str(ipv4_mtu)
+ if iface["mac_address"] is not None:
+ self.config["ethernet"]["mac-address"] = self.mac_addr(
+ iface["mac_address"]
+ )
+ if if_type == "vlan" and "vlan-raw-device" in iface:
+ self.config["vlan"]["parent"] = renderer.con_ref(
+ iface["vlan-raw-device"]
+ )
+ if if_type == "bridge":
+ # Bridge is ass-backwards compared to bond
+ for port in iface["bridge_ports"]:
+ port = renderer.get_conn(port)
+ port._set_default("connection", "slave-type", "bridge")
+ port._set_default("connection", "master", self.con_uuid())
+ if iface["mac_address"] is not None:
+ self.config["bridge"]["mac-address"] = self.mac_addr(
+ iface["mac_address"]
+ )
+ if if_type == "infiniband" and ipv4_mtu is not None:
+ self.config["infiniband"]["transport-mode"] = "datagram"
+ self.config["infiniband"]["mtu"] = str(ipv4_mtu)
+ if iface["mac_address"] is not None:
+ self.config["infiniband"]["mac-address"] = self.mac_addr(
+ iface["mac_address"]
+ )
+
+ # Finish up
+ if if_type == "bridge" or not self.config.has_option(
+ if_type, "mac-address"
+ ):
+ self.config["connection"]["interface-name"] = iface["name"]
+
+ def dump(self):
+ """
+ Stringify.
+ """
+
+ buf = io.StringIO()
+ self.config.write(buf, space_around_delimiters=False)
+ header = "# Generated by cloud-init. Changes will be lost.\n\n"
+ return header + buf.getvalue()
+
+
+class Renderer(renderer.Renderer):
+ """Renders network information in a NetworkManager keyfile format."""
+
+ def __init__(self, config=None):
+ self.connections = {}
+
+ def get_conn(self, con_id):
+ return self.connections[con_id]
+
+ def con_ref(self, con_id):
+ if con_id in self.connections:
+ return self.connections[con_id].con_uuid()
+ else:
+ # Well, what can we do...
+ return con_id
+
+ def render_network_state(
+ self,
+ network_state: NetworkState,
+ templates: Optional[dict] = None,
+ target=None,
+ ) -> None:
+ # First pass makes sure there's NMConnections for all known
+ # interfaces that have UUIDs that can be linked to from related
+ # interfaces
+ for iface in network_state.iter_interfaces():
+ self.connections[iface["name"]] = NMConnection(iface["name"])
+
+ # Now render the actual interface configuration
+ for iface in network_state.iter_interfaces():
+ conn = self.connections[iface["name"]]
+ conn.render_interface(iface, self)
+
+ # And finally write the files
+ for con_id, conn in self.connections.items():
+ if not conn.valid():
+ continue
+ name = conn_filename(con_id, target)
+ util.write_file(name, conn.dump(), 0o600)
+
+
+def conn_filename(con_id, target=None):
+ target_con_dir = subp.target_path(target, NM_RUN_DIR)
+ con_file = f"cloud-init-{con_id}.nmconnection"
+ return f"{target_con_dir}/system-connections/{con_file}"
+
+
+def available(target=None):
+ # TODO: Move `uses_systemd` to a more appropriate location
+ # It is imported here to avoid circular import
+ from cloudinit.distros import uses_systemd
+
+ config_present = os.path.isfile(subp.target_path(target, path=NM_CFG_FILE))
+ nmcli_present = subp.which("nmcli", target=target)
+ service_active = True
+ if uses_systemd():
+ try:
+ subp.subp(["systemctl", "is-enabled", "NetworkManager.service"])
+ except subp.ProcessExecutionError:
+ service_active = False
+
+ return config_present and bool(nmcli_present) and service_active
+
+
+# vi: ts=4 expandtab
diff --git a/cloudinit/net/renderers.py b/cloudinit/net/renderers.py
index 022ff938..fcf7feba 100644
--- a/cloudinit/net/renderers.py
+++ b/cloudinit/net/renderers.py
@@ -8,6 +8,7 @@ from cloudinit.net import (
freebsd,
netbsd,
netplan,
+ network_manager,
networkd,
openbsd,
renderer,
@@ -19,6 +20,7 @@ NAME_TO_RENDERER = {
"freebsd": freebsd,
"netbsd": netbsd,
"netplan": netplan,
+ "network-manager": network_manager,
"networkd": networkd,
"openbsd": openbsd,
"sysconfig": sysconfig,
diff --git a/cloudinit/net/sysconfig.py b/cloudinit/net/sysconfig.py
index e08c0c69..b8786fb7 100644
--- a/cloudinit/net/sysconfig.py
+++ b/cloudinit/net/sysconfig.py
@@ -6,8 +6,6 @@ import os
import re
from typing import Mapping, Optional
-from configobj import ConfigObj
-
from cloudinit import log as logging
from cloudinit import subp, util
from cloudinit.distros.parsers import networkmanager_conf, resolv_conf
@@ -37,7 +35,7 @@ KNOWN_DISTROS = [
"TencentOS",
"virtuozzo",
]
-NM_CFG_FILE = "/etc/NetworkManager/NetworkManager.conf"
+
def _make_header(sep="#"):
lines = [
@@ -68,26 +66,7 @@ def _quote_value(value):
return value
-
-def enable_ifcfg_rh(path):
- """Add ifcfg-rh to NetworkManager.cfg plugins if main section is present"""
- config = ConfigObj(path)
- if "main" in config:
- if "plugins" in config["main"]:
- if "ifcfg-rh" in config["main"]["plugins"]:
- return
- else:
- config["main"]["plugins"] = []
-
- if isinstance(config["main"]["plugins"], list):
- config["main"]["plugins"].append("ifcfg-rh")
- else:
- config["main"]["plugins"] = [config["main"]["plugins"], "ifcfg-rh"]
- config.write()
- LOG.debug("Enabled ifcfg-rh NetworkManager plugins")
-
-
-class ConfigMap(object):
+class ConfigMap:
"""Sysconfig like dictionary object."""
# Why does redhat prefer yes/no to true/false??
@@ -1040,8 +1019,6 @@ class Renderer(renderer.Renderer):
mode=file_mode,
preserve_mode=True,
)
- if available_nm(target=target):
- enable_ifcfg_rh(subp.target_path(target, path=NM_CFG_FILE))
sysconfig_path = subp.target_path(target, templates.get("control"))
# Distros configuring /etc/sysconfig/network as a file e.g. Centos
@@ -1080,14 +1057,9 @@ def _supported_vlan_names(rdev, vid):
def available(target=None):
- sysconfig = available_sysconfig(target=target)
- nm = available_nm(target=target)
- return util.system_info()["variant"] in KNOWN_DISTROS and any(
- [nm, sysconfig]
- )
-
+ if not util.system_info()["variant"] in KNOWN_DISTROS:
+ return False
-def available_sysconfig(target=None):
expected = ["ifup", "ifdown"]
search = ["/sbin", "/usr/sbin"]
for p in expected:
@@ -1104,10 +1076,4 @@ def available_sysconfig(target=None):
return False
-def available_nm(target=None):
- if not os.path.isfile(subp.target_path(target, path=NM_CFG_FILE)):
- return False
- return True
-
-
# vi: ts=4 expandtab
diff --git a/tests/unittests/test_net.py b/tests/unittests/test_net.py
index 4434b350..0f523ff8 100644
--- a/tests/unittests/test_net.py
+++ b/tests/unittests/test_net.py
@@ -23,6 +23,7 @@ from cloudinit.net import (
mask_and_ipv4_to_bcast_addr,
natural_sort_key,
netplan,
+ network_manager,
network_state,
networkd,
renderers,
@@ -616,6 +617,37 @@ dns = none
),
),
],
+ "expected_network_manager": [
+ (
+ "".join(
+ [
+ "etc/NetworkManager/system-connections",
+ "/cloud-init-eth0.nmconnection",
+ ]
+ ),
+ """
+# Generated by cloud-init. Changes will be lost.
+
+[connection]
+id=cloud-init eth0
+uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
+type=ethernet
+
+[user]
+org.freedesktop.NetworkManager.origin=cloud-init
+
+[ethernet]
+mac-address=FA:16:3E:ED:9A:59
+
+[ipv4]
+method=manual
+may-fail=false
+address1=172.19.1.34/22
+route1=0.0.0.0/0,172.19.3.254
+
+""".lstrip(),
+ ),
+ ],
},
{
"in_data": {
@@ -1078,6 +1110,50 @@ NETWORK_CONFIGS = {
USERCTL=no"""
),
},
+ "expected_network_manager": {
+ "cloud-init-eth1.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init eth1
+ uuid=3c50eb47-7260-5a6d-801d-bd4f587d6b58
+ type=ethernet
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ mac-address=CF:D6:AF:48:E8:80
+
+ """
+ ),
+ "cloud-init-eth99.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init eth99
+ uuid=b1b88000-1f03-5360-8377-1a2205efffb4
+ type=ethernet
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ mac-address=C0:D6:9F:2C:E8:80
+
+ [ipv4]
+ method=auto
+ may-fail=false
+ address1=192.168.21.3/24
+ route1=0.0.0.0/0,65.61.151.37
+ dns=8.8.8.8;8.8.4.4;
+ dns-search=barley.maas;sach.maas;
+
+ """
+ ),
+ },
"yaml": textwrap.dedent(
"""
version: 1
@@ -1883,6 +1959,29 @@ NETWORK_CONFIGS = {
"""
),
},
+ "expected_network_manager": {
+ "cloud-init-iface0.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init iface0
+ uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
+ type=ethernet
+ interface-name=iface0
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+
+ [ipv4]
+ method=auto
+ may-fail=false
+
+ """
+ ),
+ },
"yaml_v2": textwrap.dedent(
"""\
version: 2
@@ -1936,6 +2035,30 @@ NETWORK_CONFIGS = {
"""
),
},
+ "expected_network_manager": {
+ "cloud-init-iface0.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init iface0
+ uuid=8ddfba48-857c-5e86-ac09-1b43eae0bf70
+ type=ethernet
+ interface-name=iface0
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ wake-on-lan=64
+
+ [ipv4]
+ method=auto
+ may-fail=false
+
+ """
+ ),
+ },
"yaml_v2": textwrap.dedent(
"""\
version: 2
@@ -2969,8 +3092,8 @@ iface bond0 inet6 static
- to: 2001:67c:1562:8007::1/64
via: 2001:67c:1562:8007::aac:40b2
- metric: 10000
- to: 3001:67c:1562:8007::1/64
- via: 3001:67c:1562:8007::aac:40b2
+ to: 3001:67c:15:8007::1/64
+ via: 3001:67c:15:8007::aac:40b2
"""
),
"expected_netplan-v2": textwrap.dedent(
@@ -3002,8 +3125,8 @@ iface bond0 inet6 static
- to: 2001:67c:1562:8007::1/64
via: 2001:67c:1562:8007::aac:40b2
- metric: 10000
- to: 3001:67c:1562:8007::1/64
- via: 3001:67c:1562:8007::aac:40b2
+ to: 3001:67c:15:8007::1/64
+ via: 3001:67c:15:8007::aac:40b2
ethernets:
eth0:
match:
@@ -3651,6 +3774,73 @@ iface bond0 inet6 static
"""
),
},
+ "expected_network_manager": {
+ "cloud-init-eth0.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init eth0
+ uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
+ type=ethernet
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ mac-address=52:54:00:12:34:00
+
+ [ipv4]
+ method=manual
+ may-fail=false
+ address1=192.168.1.2/24
+
+ """
+ ),
+ "cloud-init-eth1.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init eth1
+ uuid=3c50eb47-7260-5a6d-801d-bd4f587d6b58
+ type=ethernet
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ mtu=1480
+ mac-address=52:54:00:12:34:AA
+
+ [ipv4]
+ method=auto
+ may-fail=true
+
+ """
+ ),
+ "cloud-init-eth2.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init eth2
+ uuid=5559a242-3421-5fdd-896e-9cb8313d5804
+ type=ethernet
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ mac-address=52:54:00:12:34:FF
+
+ [ipv4]
+ method=auto
+ may-fail=true
+
+ """
+ ),
+ },
},
"v2-dev-name-via-mac-lookup": {
"expected_sysconfig_rhel": {
@@ -4149,7 +4339,6 @@ class TestRhelSysConfigRendering(CiTestCase):
with_logs = True
- nm_cfg_file = "/etc/NetworkManager/NetworkManager.conf"
scripts_dir = "/etc/sysconfig/network-scripts"
header = (
"# Created by cloud-init on instance boot automatically, "
@@ -4724,78 +4913,6 @@ USERCTL=no
self._compare_files_to_expected(entry[self.expected_name], found)
self._assert_headers(found)
- def test_check_ifcfg_rh(self):
- """ifcfg-rh plugin is added NetworkManager.conf if conf present."""
- render_dir = self.tmp_dir()
- nm_cfg = subp.target_path(render_dir, path=self.nm_cfg_file)
- util.ensure_dir(os.path.dirname(nm_cfg))
-
- # write a template nm.conf, note plugins is a list here
- with open(nm_cfg, "w") as fh:
- fh.write("# test_check_ifcfg_rh\n[main]\nplugins=foo,bar\n")
- self.assertTrue(os.path.exists(nm_cfg))
-
- # render and read
- entry = NETWORK_CONFIGS["small"]
- found = self._render_and_read(
- network_config=yaml.load(entry["yaml"]), dir=render_dir
- )
- self._compare_files_to_expected(entry[self.expected_name], found)
- self._assert_headers(found)
-
- # check ifcfg-rh is in the 'plugins' list
- config = sysconfig.ConfigObj(nm_cfg)
- self.assertIn("ifcfg-rh", config["main"]["plugins"])
-
- def test_check_ifcfg_rh_plugins_string(self):
- """ifcfg-rh plugin is append when plugins is a string."""
- render_dir = self.tmp_path("render")
- os.makedirs(render_dir)
- nm_cfg = subp.target_path(render_dir, path=self.nm_cfg_file)
- util.ensure_dir(os.path.dirname(nm_cfg))
-
- # write a template nm.conf, note plugins is a value here
- util.write_file(nm_cfg, "# test_check_ifcfg_rh\n[main]\nplugins=foo\n")
-
- # render and read
- entry = NETWORK_CONFIGS["small"]
- found = self._render_and_read(
- network_config=yaml.load(entry["yaml"]), dir=render_dir
- )
- self._compare_files_to_expected(entry[self.expected_name], found)
- self._assert_headers(found)
-
- # check raw content has plugin
- nm_file_content = util.load_file(nm_cfg)
- self.assertIn("ifcfg-rh", nm_file_content)
-
- # check ifcfg-rh is in the 'plugins' list
- config = sysconfig.ConfigObj(nm_cfg)
- self.assertIn("ifcfg-rh", config["main"]["plugins"])
-
- def test_check_ifcfg_rh_plugins_no_plugins(self):
- """enable_ifcfg_plugin creates plugins value if missing."""
- render_dir = self.tmp_path("render")
- os.makedirs(render_dir)
- nm_cfg = subp.target_path(render_dir, path=self.nm_cfg_file)
- util.ensure_dir(os.path.dirname(nm_cfg))
-
- # write a template nm.conf, note plugins is missing
- util.write_file(nm_cfg, "# test_check_ifcfg_rh\n[main]\n")
- self.assertTrue(os.path.exists(nm_cfg))
-
- # render and read
- entry = NETWORK_CONFIGS["small"]
- found = self._render_and_read(
- network_config=yaml.load(entry["yaml"]), dir=render_dir
- )
- self._compare_files_to_expected(entry[self.expected_name], found)
- self._assert_headers(found)
-
- # check ifcfg-rh is in the 'plugins' list
- config = sysconfig.ConfigObj(nm_cfg)
- self.assertIn("ifcfg-rh", config["main"]["plugins"])
-
def test_netplan_dhcp_false_disable_dhcp_in_state(self):
"""netplan config with dhcp[46]: False should not add dhcp in state"""
net_config = yaml.load(NETPLAN_DHCP_FALSE)
@@ -5492,6 +5609,281 @@ STARTMODE=auto
self._assert_headers(found)
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False),
+)
+class TestNetworkManagerRendering(CiTestCase):
+
+ with_logs = True
+
+ scripts_dir = "/etc/NetworkManager/system-connections"
+
+ expected_name = "expected_network_manager"
+
+ def _get_renderer(self):
+ return network_manager.Renderer()
+
+ def _render_and_read(self, network_config=None, state=None, dir=None):
+ if dir is None:
+ dir = self.tmp_dir()
+
+ if network_config:
+ ns = network_state.parse_net_config_data(network_config)
+ elif state:
+ ns = state
+ else:
+ raise ValueError("Expected data or state, got neither")
+
+ renderer = self._get_renderer()
+ renderer.render_network_state(ns, target=dir)
+ return dir2dict(dir)
+
+ def _compare_files_to_expected(self, expected, found):
+ orig_maxdiff = self.maxDiff
+ expected_d = dict(
+ (os.path.join(self.scripts_dir, k), v) for k, v in expected.items()
+ )
+
+ try:
+ self.maxDiff = None
+ self.assertEqual(expected_d, found)
+ finally:
+ self.maxDiff = orig_maxdiff
+
+ @mock.patch("cloudinit.net.util.get_cmdline", return_value="root=myroot")
+ @mock.patch("cloudinit.net.sys_dev_path")
+ @mock.patch("cloudinit.net.read_sys_net")
+ @mock.patch("cloudinit.net.get_devicelist")
+ def test_default_generation(
+ self,
+ mock_get_devicelist,
+ mock_read_sys_net,
+ mock_sys_dev_path,
+ m_get_cmdline,
+ ):
+ tmp_dir = self.tmp_dir()
+ _setup_test(
+ tmp_dir, mock_get_devicelist, mock_read_sys_net, mock_sys_dev_path
+ )
+
+ network_cfg = net.generate_fallback_config()
+ ns = network_state.parse_net_config_data(
+ network_cfg, skip_broken=False
+ )
+
+ render_dir = os.path.join(tmp_dir, "render")
+ os.makedirs(render_dir)
+
+ renderer = self._get_renderer()
+ renderer.render_network_state(ns, target=render_dir)
+
+ found = dir2dict(render_dir)
+ self._compare_files_to_expected(
+ {
+ "cloud-init-eth1000.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init eth1000
+ uuid=8c517500-0c95-5308-9c8a-3092eebc44eb
+ type=ethernet
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ mac-address=07:1C:C6:75:A4:BE
+
+ [ipv4]
+ method=auto
+ may-fail=false
+
+ """
+ ),
+ },
+ found,
+ )
+
+ def test_openstack_rendering_samples(self):
+ for os_sample in OS_SAMPLES:
+ render_dir = self.tmp_dir()
+ ex_input = os_sample["in_data"]
+ ex_mac_addrs = os_sample["in_macs"]
+ network_cfg = openstack.convert_net_json(
+ ex_input, known_macs=ex_mac_addrs
+ )
+ ns = network_state.parse_net_config_data(
+ network_cfg, skip_broken=False
+ )
+ renderer = self._get_renderer()
+ # render a multiple times to simulate reboots
+ renderer.render_network_state(ns, target=render_dir)
+ renderer.render_network_state(ns, target=render_dir)
+ renderer.render_network_state(ns, target=render_dir)
+ for fn, expected_content in os_sample.get(self.expected_name, []):
+ with open(os.path.join(render_dir, fn)) as fh:
+ self.assertEqual(expected_content, fh.read())
+
+ def test_network_config_v1_samples(self):
+ ns = network_state.parse_net_config_data(CONFIG_V1_SIMPLE_SUBNET)
+ render_dir = self.tmp_path("render")
+ os.makedirs(render_dir)
+ renderer = self._get_renderer()
+ renderer.render_network_state(ns, target=render_dir)
+ found = dir2dict(render_dir)
+ self._compare_files_to_expected(
+ {
+ "cloud-init-interface0.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init interface0
+ uuid=8b6862ed-dbd6-5830-93f7-a91451c13828
+ type=ethernet
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+ mac-address=52:54:00:12:34:00
+
+ [ipv4]
+ method=manual
+ may-fail=false
+ address1=10.0.2.15/24
+ gateway=10.0.2.2
+
+ """
+ ),
+ },
+ found,
+ )
+
+ def test_config_with_explicit_loopback(self):
+ render_dir = self.tmp_path("render")
+ os.makedirs(render_dir)
+ ns = network_state.parse_net_config_data(CONFIG_V1_EXPLICIT_LOOPBACK)
+ renderer = self._get_renderer()
+ renderer.render_network_state(ns, target=render_dir)
+ found = dir2dict(render_dir)
+ self._compare_files_to_expected(
+ {
+ "cloud-init-eth0.nmconnection": textwrap.dedent(
+ """\
+ # Generated by cloud-init. Changes will be lost.
+
+ [connection]
+ id=cloud-init eth0
+ uuid=1dd9a779-d327-56e1-8454-c65e2556c12c
+ type=ethernet
+ interface-name=eth0
+
+ [user]
+ org.freedesktop.NetworkManager.origin=cloud-init
+
+ [ethernet]
+
+ [ipv4]
+ method=auto
+ may-fail=false
+
+ """
+ ),
+ },
+ found,
+ )
+
+ def test_bond_config(self):
+ entry = NETWORK_CONFIGS["bond"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_vlan_config(self):
+ entry = NETWORK_CONFIGS["vlan"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_bridge_config(self):
+ entry = NETWORK_CONFIGS["bridge"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_manual_config(self):
+ entry = NETWORK_CONFIGS["manual"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_all_config(self):
+ entry = NETWORK_CONFIGS["all"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+ self.assertNotIn(
+ "WARNING: Network config: ignoring eth0.101 device-level mtu",
+ self.logs.getvalue(),
+ )
+
+ def test_small_config(self):
+ entry = NETWORK_CONFIGS["small"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_v4_and_v6_static_config(self):
+ entry = NETWORK_CONFIGS["v4_and_v6_static"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+ expected_msg = (
+ "WARNING: Network config: ignoring iface0 device-level mtu:8999"
+ " because ipv4 subnet-level mtu:9000 provided."
+ )
+ self.assertIn(expected_msg, self.logs.getvalue())
+
+ def test_dhcpv6_only_config(self):
+ entry = NETWORK_CONFIGS["dhcpv6_only"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_simple_render_ipv6_slaac(self):
+ entry = NETWORK_CONFIGS["ipv6_slaac"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_dhcpv6_stateless_config(self):
+ entry = NETWORK_CONFIGS["dhcpv6_stateless"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_wakeonlan_disabled_config_v2(self):
+ entry = NETWORK_CONFIGS["wakeonlan_disabled"]
+ found = self._render_and_read(
+ network_config=yaml.load(entry["yaml_v2"])
+ )
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_wakeonlan_enabled_config_v2(self):
+ entry = NETWORK_CONFIGS["wakeonlan_enabled"]
+ found = self._render_and_read(
+ network_config=yaml.load(entry["yaml_v2"])
+ )
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_render_v4_and_v6(self):
+ entry = NETWORK_CONFIGS["v4_and_v6"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+ def test_render_v6_and_v4(self):
+ entry = NETWORK_CONFIGS["v6_and_v4"]
+ found = self._render_and_read(network_config=yaml.load(entry["yaml"]))
+ self._compare_files_to_expected(entry[self.expected_name], found)
+
+
+@mock.patch(
+ "cloudinit.net.is_openvswitch_internal_interface",
+ mock.Mock(return_value=False),
+)
class TestEniNetRendering(CiTestCase):
@mock.patch("cloudinit.net.util.get_cmdline", return_value="root=myroot")
@mock.patch("cloudinit.net.sys_dev_path")
@@ -7259,9 +7651,9 @@ class TestNetworkdRoundTrip(CiTestCase):
class TestRenderersSelect:
@pytest.mark.parametrize(
- "renderer_selected,netplan,eni,nm,scfg,sys,networkd",
+ "renderer_selected,netplan,eni,sys,network_manager,networkd",
(
- # -netplan -ifupdown -nm -scfg -sys raises error
+ # -netplan -ifupdown -sys -network-manager -networkd raises error
(
net.RendererNotFoundError,
False,
@@ -7269,52 +7661,51 @@ class TestRenderersSelect:
False,
False,
False,
- False,
),
- # -netplan +ifupdown -nm -scfg -sys selects eni
- ("eni", False, True, False, False, False, False),
- # +netplan +ifupdown -nm -scfg -sys selects eni
- ("eni", True, True, False, False, False, False),
- # +netplan -ifupdown -nm -scfg -sys selects netplan
- ("netplan", True, False, False, False, False, False),
- # Ubuntu with Network-Manager installed
- # +netplan -ifupdown +nm -scfg -sys selects netplan
- ("netplan", True, False, True, False, False, False),
- # Centos/OpenSuse with Network-Manager installed selects sysconfig
- # -netplan -ifupdown +nm -scfg +sys selects netplan
- ("sysconfig", False, False, True, False, True, False),
- # -netplan -ifupdown -nm -scfg -sys +networkd selects networkd
- ("networkd", False, False, False, False, False, True),
+ # -netplan +ifupdown -sys -nm -networkd selects eni
+ ("eni", False, True, False, False, False),
+ # +netplan +ifupdown -sys -nm -networkd selects eni
+ ("eni", True, True, False, False, False),
+ # +netplan -ifupdown -sys -nm -networkd selects netplan
+ ("netplan", True, False, False, False, False),
+ # +netplan -ifupdown -sys -nm -networkd selects netplan
+ ("netplan", True, False, False, False, False),
+ # -netplan -ifupdown +sys -nm -networkd selects sysconfig
+ ("sysconfig", False, False, True, False, False),
+ # -netplan -ifupdown +sys +nm -networkd selects sysconfig
+ ("sysconfig", False, False, True, True, False),
+ # -netplan -ifupdown -sys +nm -networkd selects nm
+ ("network-manager", False, False, False, True, False),
+ # -netplan -ifupdown -sys +nm +networkd selects nm
+ ("network-manager", False, False, False, True, True),
+ # -netplan -ifupdown -sys -nm +networkd selects networkd
+ ("networkd", False, False, False, False, True),
),
)
@mock.patch("cloudinit.net.renderers.networkd.available")
+ @mock.patch("cloudinit.net.renderers.network_manager.available")
@mock.patch("cloudinit.net.renderers.netplan.available")
@mock.patch("cloudinit.net.renderers.sysconfig.available")
- @mock.patch("cloudinit.net.renderers.sysconfig.available_sysconfig")
- @mock.patch("cloudinit.net.renderers.sysconfig.available_nm")
@mock.patch("cloudinit.net.renderers.eni.available")
def test_valid_renderer_from_defaults_depending_on_availability(
self,
m_eni_avail,
- m_nm_avail,
- m_scfg_avail,
m_sys_avail,
m_netplan_avail,
+ m_network_manager_avail,
m_networkd_avail,
renderer_selected,
netplan,
eni,
- nm,
- scfg,
sys,
+ network_manager,
networkd,
):
"""Assert proper renderer per DEFAULT_PRIORITY given availability."""
m_eni_avail.return_value = eni # ifupdown pkg presence
- m_nm_avail.return_value = nm # network-manager presence
- m_scfg_avail.return_value = scfg # sysconfig presence
m_sys_avail.return_value = sys # sysconfig/ifup/down presence
m_netplan_avail.return_value = netplan # netplan presence
+ m_network_manager_avail.return_value = network_manager # NM presence
m_networkd_avail.return_value = networkd # networkd presence
if isinstance(renderer_selected, str):
(renderer_name, _rnd_class) = renderers.select(
@@ -7372,7 +7763,7 @@ class TestNetRenderers(CiTestCase):
priority=["sysconfig", "eni"],
)
- @mock.patch("cloudinit.net.sysconfig.available_sysconfig")
+ @mock.patch("cloudinit.net.sysconfig.available")
@mock.patch("cloudinit.util.system_info")
def test_sysconfig_available_uses_variant_mapping(self, m_info, m_avail):
m_avail.return_value = True
diff --git a/tests/unittests/test_net_activators.py b/tests/unittests/test_net_activators.py
index b735ea9e..afd9056a 100644
--- a/tests/unittests/test_net_activators.py
+++ b/tests/unittests/test_net_activators.py
@@ -139,10 +139,6 @@ NETPLAN_AVAILABLE_CALLS = [
(("netplan",), {"search": ["/usr/sbin", "/sbin"], "target": None}),
]
-NETWORK_MANAGER_AVAILABLE_CALLS = [
- (("nmcli",), {"target": None}),
-]
-
NETWORKD_AVAILABLE_CALLS = [
(("ip",), {"search": ["/usr/sbin", "/bin"], "target": None}),
(("systemctl",), {"search": ["/usr/sbin", "/bin"], "target": None}),
@@ -154,7 +150,6 @@ NETWORKD_AVAILABLE_CALLS = [
[
(IfUpDownActivator, IF_UP_DOWN_AVAILABLE_CALLS),
(NetplanActivator, NETPLAN_AVAILABLE_CALLS),
- (NetworkManagerActivator, NETWORK_MANAGER_AVAILABLE_CALLS),
(NetworkdActivator, NETWORKD_AVAILABLE_CALLS),
],
)
@@ -259,9 +254,11 @@ class TestActivatorsBringUp:
def test_bring_up_interface(
self, m_subp, activator, expected_call_list, available_mocks
):
+ index = 0
activator.bring_up_interface("eth0")
- assert len(m_subp.call_args_list) == 1
- assert m_subp.call_args_list[0] == expected_call_list[0]
+ for call in m_subp.call_args_list:
+ assert call == expected_call_list[index]
+ index += 1
@patch("cloudinit.subp.subp", return_value=("", ""))
def test_bring_up_interfaces(