From 5822f72230a58d18dae8c3b76c02d4bf7a149e56 Mon Sep 17 00:00:00 2001 From: Ani Sinha 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 --- 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 +# 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 or route, ensuring + the appropriate value gets used for . + """ + + 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 property. + """ + + value = subnet["address"] + "/" + str(subnet["prefix"]) + self._add_numbered(family, "address", value) + + def _add_route(self, family, route): + """ + Adds a ipv[46].route 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(