diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py index 2957908ef32..6c8d121af4d 100644 --- a/cloudinit/distros/__init__.py +++ b/cloudinit/distros/__init__.py @@ -112,14 +112,18 @@ class Distro(persistence.CloudInitPickleMixin, metaclass=abc.ABCMeta): resolve_conf_fn = "/etc/resolv.conf" osfamily: str - dhcp_client_priority = [dhcp.IscDhclient, dhcp.Dhcpcd] + dhcp_client_priority = [dhcp.IscDhclient, dhcp.Dhcpcd, dhcp.Udhcpc] def __init__(self, name, cfg, paths): self._paths = paths self._cfg = cfg self.name = name self.networking: Networking = self.networking_cls() - self.dhcp_client_priority = [dhcp.IscDhclient, dhcp.Dhcpcd] + self.dhcp_client_priority = [ + dhcp.IscDhclient, + dhcp.Dhcpcd, + dhcp.Udhcpc, + ] self.net_ops = iproute2.Iproute2 def _unpickle(self, ci_pkl_version: int) -> None: diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py index 75a47efaa58..5cf961c51c3 100644 --- a/cloudinit/net/dhcp.py +++ b/cloudinit/net/dhcp.py @@ -21,6 +21,7 @@ from cloudinit.net import ( find_fallback_nic, get_devicelist, + get_ib_interface_hwaddr, get_interface_mac, is_ib_interface, ) @@ -28,6 +29,37 @@ LOG = logging.getLogger(__name__) NETWORKD_LEASES_DIR = "/run/systemd/netif/leases" +UDHCPC_SCRIPT = """#!/bin/sh +log() { + echo "udhcpc[$PPID]" "$interface: $2" +} +[ -z "$1" ] && echo "Error: should be called from udhcpc" && exit 1 +case $1 in + bound|renew) + cat < "$LEASE_FILE" +{ + "interface": "$interface", + "fixed-address": "$ip", + "subnet-mask": "$subnet", + "routers": "${router%% *}", + "static_routes" : "${staticroutes}" +} +JSON + ;; + deconfig) + log err "Not supported" + exit 1 + ;; + leasefail | nak) + log err "configuration failed: $1: $message" + exit 1 + ;; + *) + echo "$0: Unknown udhcpc command: $1" >&2 + exit 1 + ;; +esac +""" class NoDHCPLeaseError(Exception): @@ -50,6 +82,10 @@ class NoDHCPLeaseMissingDhclientError(NoDHCPLeaseError): """Raised when unable to find dhclient.""" +class NoDHCPLeaseMissingUdhcpcError(NoDHCPLeaseError): + """Raised when unable to find udhcpc client.""" + + def select_dhcp_client(distro): """distros set priority list, select based on this order which to use @@ -60,7 +96,10 @@ def select_dhcp_client(distro): dhcp_client = client() LOG.debug("DHCP client selected: %s", client.client_name) return dhcp_client - except NoDHCPLeaseMissingDhclientError: + except ( + NoDHCPLeaseMissingDhclientError, + NoDHCPLeaseMissingUdhcpcError, + ): LOG.warning("DHCP client not found: %s", client.client_name) raise NoDHCPLeaseMissingDhclientError() @@ -492,3 +531,91 @@ class Dhcpcd: def __init__(self): raise NoDHCPLeaseMissingDhclientError("Dhcpcd not yet implemented") + + +class Udhcpc(DhcpClient): + client_name = "udhcpc" + + def __init__(self): + self.udhcpc_path = subp.which("udhcpc") + if not self.udhcpc_path: + LOG.debug("Skip udhcpc configuration: No udhcpc command found.") + raise NoDHCPLeaseMissingUdhcpcError() + + def dhcp_discovery( + self, + interface, + dhcp_log_func=None, + distro=None, + ): + """Run udhcpc on the interface without scripts or filesystem artifacts. + + @param interface: Name of the network interface on which to run udhcpc. + @param dhcp_log_func: A callable accepting the udhcpc output and + error streams. + + @return: A list of dicts of representing the dhcp leases parsed from + the udhcpc lease file. + """ + LOG.debug("Performing a dhcp discovery on %s", interface) + + tmp_dir = temp_utils.get_tmp_ancestor(needs_exe=True) + lease_file = os.path.join(tmp_dir, interface + ".lease.json") + with contextlib.suppress(FileNotFoundError): + os.remove(lease_file) + + # udhcpc needs the interface up to send initial discovery packets + distro.net_ops.link_up(interface) + + udhcpc_script = os.path.join(tmp_dir, "udhcpc_script") + util.write_file(udhcpc_script, UDHCPC_SCRIPT, 0o755) + + cmd = [ + self.udhcpc_path, + "-O", + "staticroutes", + "-i", + interface, + "-s", + udhcpc_script, + "-n", # Exit if lease is not obtained + "-q", # Exit after obtaining lease + "-f", # Run in foreground + "-v", + ] + + # For INFINIBAND port the dhcpc must be running with + # client id option. So here we are checking if the interface is + # INFINIBAND or not. If yes, we are generating the the client-id to be + # used with the udhcpc + if is_ib_interface(interface): + dhcp_client_identifier = get_ib_interface_hwaddr( + interface, ethernet_format=True + ) + cmd.extend( + ["-x", "0x3d:%s" % dhcp_client_identifier.replace(":", "")] + ) + try: + out, err = subp.subp( + cmd, update_env={"LEASE_FILE": lease_file}, capture=True + ) + except subp.ProcessExecutionError as error: + LOG.debug( + "udhcpc exited with code: %s stderr: %r stdout: %r", + error.exit_code, + error.stderr, + error.stdout, + ) + raise NoDHCPLeaseError from error + + if dhcp_log_func is not None: + dhcp_log_func(out, err) + + lease_json = util.load_json(util.load_file(lease_file)) + static_routes = lease_json["static_routes"].split() + if static_routes: + # format: dest1/mask gw1 ... destn/mask gwn + lease_json["static_routes"] = [ + i for i in zip(static_routes[::2], static_routes[1::2]) + ] + return [lease_json] diff --git a/tests/unittests/net/test_dhcp.py b/tests/unittests/net/test_dhcp.py index 588f9bccdec..a7b62312757 100644 --- a/tests/unittests/net/test_dhcp.py +++ b/tests/unittests/net/test_dhcp.py @@ -13,6 +13,7 @@ NoDHCPLeaseError, NoDHCPLeaseInterfaceError, NoDHCPLeaseMissingDhclientError, + Udhcpc, maybe_perform_dhcp_discovery, networkd_load_leases, ) @@ -928,3 +929,169 @@ def test_ctx_mgr_umbrella_error(self, m_dhcp, error_class): pass assert len(m_dhcp.mock_calls) == 1 + + +class TestUDHCPCDiscoveryClean(CiTestCase): + with_logs = True + maxDiff = None + + @mock.patch("cloudinit.net.dhcp.subp.which") + @mock.patch("cloudinit.net.dhcp.find_fallback_nic") + def test_absent_udhcpc_command(self, m_fallback, m_which): + """When dhclient doesn't exist in the OS, log the issue and no-op.""" + m_fallback.return_value = "eth9" + m_which.return_value = None # udhcpc isn't found + + distro = MockDistro() + distro.dhcp_client_priority = [Udhcpc] + + with pytest.raises(NoDHCPLeaseMissingDhclientError): + maybe_perform_dhcp_discovery(distro) + + self.assertIn( + "Skip udhcpc configuration: No udhcpc command found.", + self.logs.getvalue(), + ) + + @mock.patch("cloudinit.net.dhcp.is_ib_interface", return_value=False) + @mock.patch("cloudinit.net.dhcp.subp.which", return_value="/sbin/udhcpc") + @mock.patch("cloudinit.net.dhcp.os.remove") + @mock.patch("cloudinit.net.dhcp.subp.subp") + @mock.patch("cloudinit.util.load_json") + @mock.patch("cloudinit.util.load_file") + @mock.patch("cloudinit.util.write_file") + def test_udhcpc_discovery( + self, + m_write_file, + m_load_file, + m_loadjson, + m_subp, + m_remove, + m_which, + mocked_is_ib_interface, + ): + """dhcp_discovery runs udcpc and parse the dhcp leases.""" + m_subp.return_value = ("", "") + m_loadjson.return_value = { + "interface": "eth9", + "fixed-address": "192.168.2.74", + "subnet-mask": "255.255.255.0", + "routers": "192.168.2.1", + "static_routes": "10.240.0.1/32 0.0.0.0 0.0.0.0/0 10.240.0.1", + } + self.assertEqual( + [ + { + "fixed-address": "192.168.2.74", + "interface": "eth9", + "routers": "192.168.2.1", + "static_routes": [ + ("10.240.0.1/32", "0.0.0.0"), + ("0.0.0.0/0", "10.240.0.1"), + ], + "subnet-mask": "255.255.255.0", + } + ], + Udhcpc().dhcp_discovery("eth9", distro=MockDistro()), + ) + # Interface was brought up before dhclient called + m_subp.assert_has_calls( + [ + mock.call( + ["ip", "link", "set", "dev", "eth9", "up"], + ), + mock.call( + [ + "/sbin/udhcpc", + "-O", + "staticroutes", + "-i", + "eth9", + "-s", + "/var/tmp/cloud-init/udhcpc_script", + "-n", + "-q", + "-f", + "-v", + ], + update_env={ + "LEASE_FILE": "/var/tmp/cloud-init/eth9.lease.json" + }, + capture=True, + ), + ] + ) + + @mock.patch("cloudinit.net.dhcp.is_ib_interface", return_value=True) + @mock.patch("cloudinit.net.dhcp.get_ib_interface_hwaddr") + @mock.patch("cloudinit.net.dhcp.subp.which", return_value="/sbin/udhcpc") + @mock.patch("cloudinit.net.dhcp.os.remove") + @mock.patch("cloudinit.net.dhcp.subp.subp") + @mock.patch("cloudinit.util.load_json") + @mock.patch("cloudinit.util.load_file") + @mock.patch("cloudinit.util.write_file") + def test_udhcpc_discovery_ib( + self, + m_write_file, + m_load_file, + m_loadjson, + m_subp, + m_remove, + m_which, + m_get_ib_interface_hwaddr, + m_is_ib_interface, + ): + """dhcp_discovery runs udcpc and parse the dhcp leases.""" + m_subp.return_value = ("", "") + m_loadjson.return_value = { + "interface": "ib0", + "fixed-address": "192.168.2.74", + "subnet-mask": "255.255.255.0", + "routers": "192.168.2.1", + "static_routes": "10.240.0.1/32 0.0.0.0 0.0.0.0/0 10.240.0.1", + } + m_get_ib_interface_hwaddr.return_value = "00:21:28:00:01:cf:4b:01" + self.assertEqual( + [ + { + "fixed-address": "192.168.2.74", + "interface": "ib0", + "routers": "192.168.2.1", + "static_routes": [ + ("10.240.0.1/32", "0.0.0.0"), + ("0.0.0.0/0", "10.240.0.1"), + ], + "subnet-mask": "255.255.255.0", + } + ], + Udhcpc().dhcp_discovery("ib0", distro=MockDistro()), + ) + # Interface was brought up before dhclient called + m_subp.assert_has_calls( + [ + mock.call( + ["ip", "link", "set", "dev", "ib0", "up"], + ), + mock.call( + [ + "/sbin/udhcpc", + "-O", + "staticroutes", + "-i", + "ib0", + "-s", + "/var/tmp/cloud-init/udhcpc_script", + "-n", + "-q", + "-f", + "-v", + "-x", + "0x3d:0021280001cf4b01", + ], + update_env={ + "LEASE_FILE": "/var/tmp/cloud-init/ib0.lease.json" + }, + capture=True, + ), + ] + ) diff --git a/tools/.github-cla-signers b/tools/.github-cla-signers index cae09f57589..d50f96ac959 100644 --- a/tools/.github-cla-signers +++ b/tools/.github-cla-signers @@ -67,6 +67,7 @@ jacobsalmela jamesottinger Jehops jf +jfroche Jille JohnKepplers johnsonshi