Unverified Commit 25671114 authored by Jean-François Roche's avatar Jean-François Roche Committed by GitHub
Browse files

cloud-init: add udhcpc support (#226216)

* cloud-init: 22.4 -> 23.1.1

* cloud-init: add udhcpc support

Cloud-init use as dhcp client, dhclient, which is coming from the unmaintained package, isc-dhcp-client (refer https://www.isc.org/dhcp/) which ended support in 2022. dhclient is deprecated in nixos

Add patch to use `udhcpc` dhcp client coming from busybox instead.

PR based on #226173

refs #215571

upstream PR: https://github.com/canonical/cloud-init/pull/2125
parent c1e467b1
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -10,6 +10,7 @@ let cfg = config.services.cloud-init;
      openssh
      shadow
      util-linux
      busybox
    ] ++ optional cfg.btrfs.enable btrfs-progs
      ++ optional cfg.ext4.enable e2fsprogs
    ;
+4 −4
Original line number Diff line number Diff line
diff --git a/cloudinit/distros/__init__.py b/cloudinit/distros/__init__.py
index 4a468cf8..c60c899b 100644
index b82852e1..c998b21e 100644
--- a/cloudinit/distros/__init__.py
+++ b/cloudinit/distros/__init__.py
@@ -55,6 +55,7 @@ OSFAMILIES = {
         "virtuozzo",
@@ -74,6 +74,7 @@ OSFAMILIES = {
     ],
     "suse": ["opensuse", "sles"],
     "openEuler": ["openEuler"],
     "OpenCloudOS": ["OpenCloudOS", "TencentOS"],
+    "nixos": ["nixos"],
 }
 
+222 −0
Original line number Diff line number Diff line
diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py
index a9a1c980..2d83089b 100644
--- a/cloudinit/net/dhcp.py
+++ b/cloudinit/net/dhcp.py
@@ -14,12 +14,48 @@ from io import StringIO
 
 import configobj
 
-from cloudinit import subp, util
+from cloudinit import subp, util, temp_utils
 from cloudinit.net import find_fallback_nic, get_devicelist
 
 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 <<JSON > "$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):
@@ -43,12 +79,14 @@ class NoDHCPLeaseMissingDhclientError(NoDHCPLeaseError):
 
 
 def maybe_perform_dhcp_discovery(nic=None, dhcp_log_func=None, tmp_dir=None):
-    """Perform dhcp discovery if nic valid and dhclient command exists.
+    """Perform dhcp discovery if nic valid and dhclient or udhcpc command
+    exists.
 
     If the nic is invalid or undiscoverable or dhclient command is not found,
     skip dhcp_discovery and return an empty dict.
 
-    @param nic: Name of the network interface we want to run dhclient on.
+    @param nic: Name of the network interface we want to run the dhcp client
+        on.
     @param dhcp_log_func: A callable accepting the dhclient output and error
         streams.
     @param tmp_dir: Tmp dir with exec permissions.
@@ -66,11 +104,16 @@ def maybe_perform_dhcp_discovery(nic=None, dhcp_log_func=None, tmp_dir=None):
             "Skip dhcp_discovery: nic %s not found in get_devicelist.", nic
         )
         raise NoDHCPLeaseInterfaceError()
+    udhcpc_path = subp.which("udhcpc")
+    if udhcpc_path:
+        return dhcp_udhcpc_discovery(udhcpc_path, nic, dhcp_log_func)
     dhclient_path = subp.which("dhclient")
-    if not dhclient_path:
-        LOG.debug("Skip dhclient configuration: No dhclient command found.")
-        raise NoDHCPLeaseMissingDhclientError()
-    return dhcp_discovery(dhclient_path, nic, dhcp_log_func)
+    if dhclient_path:
+        return dhcp_discovery(dhclient_path, nic, dhcp_log_func)
+    LOG.debug(
+        "Skip dhclient configuration: No dhclient or udhcpc command found."
+    )
+    raise NoDHCPLeaseMissingDhclientError()
 
 
 def parse_dhcp_lease_file(lease_file):
@@ -107,6 +150,61 @@ def parse_dhcp_lease_file(lease_file):
     return dhcp_leases
 
 
+def dhcp_udhcpc_discovery(udhcpc_cmd_path, interface, dhcp_log_func=None):
+    """Run udhcpc on the interface without scripts or filesystem artifacts.
+
+    @param udhcpc_cmd_path: Full path to the udhcpc used.
+    @param interface: Name of the network interface on which to dhclient.
+    @param dhcp_log_func: A callable accepting the dhclient output and error
+        streams.
+
+    @return: A list of dicts of representing the dhcp leases parsed from the
+        dhclient.lease file or empty list.
+    """
+    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.
+    # Generally dhclient relies on dhclient-script PREINIT action to bring the
+    # link up before attempting discovery. Since we are using -sf /bin/true,
+    # we need to do that "link up" ourselves first.
+    subp.subp(["ip", "link", "set", "dev", interface, "up"], capture=True)
+    udhcpc_script = os.path.join(tmp_dir, "udhcpc_script")
+    util.write_file(udhcpc_script, UDHCPC_SCRIPT, 0o755)
+    cmd = [
+        udhcpc_cmd_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",
+    ]
+
+    out, err = subp.subp(
+        cmd, update_env={"LEASE_FILE": lease_file}, capture=True
+    )
+
+    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]
+
+
 def dhcp_discovery(dhclient_cmd_path, interface, dhcp_log_func=None):
     """Run dhclient on the interface without scripts or filesystem artifacts.
 
diff --git a/tests/unittests/net/test_dhcp.py b/tests/unittests/net/test_dhcp.py
index 40340553..8913cf65 100644
--- a/tests/unittests/net/test_dhcp.py
+++ b/tests/unittests/net/test_dhcp.py
@@ -12,6 +12,7 @@ from cloudinit.net.dhcp import (
     NoDHCPLeaseError,
     NoDHCPLeaseInterfaceError,
     NoDHCPLeaseMissingDhclientError,
+    dhcp_udhcpc_discovery,
     dhcp_discovery,
     maybe_perform_dhcp_discovery,
     networkd_load_leases,
@@ -334,6 +335,43 @@ class TestDHCPParseStaticRoutes(CiTestCase):
         )
 
 
+class TestUDHCPCDiscoveryClean(CiTestCase):
+    maxDiff = None
+
+    @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
+    ):
+        """dhcp_discovery waits for the presence of pidfile and 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",
+                }
+            ],
+            dhcp_udhcpc_discovery("/sbin/udhcpc", "eth9"),
+        )
+
+
 class TestDHCPDiscoveryClean(CiTestCase):
     with_logs = True
 
@@ -372,7 +410,7 @@ class TestDHCPDiscoveryClean(CiTestCase):
             maybe_perform_dhcp_discovery()
 
         self.assertIn(
-            "Skip dhclient configuration: No dhclient command found.",
+            "Skip dhclient configuration: No dhclient or udhcpc command found.",
             self.logs.getvalue(),
         )
 
-- 
2.38.4
+13 −7
Original line number Diff line number Diff line
@@ -10,21 +10,23 @@
, shadow
, systemd
, coreutils
, gitUpdater
, busybox
}:

python3.pkgs.buildPythonApplication rec {
  pname = "cloud-init";
  version = "22.4";
  version = "23.1.1";
  namePrefix = "";

  src = fetchFromGitHub {
    owner = "canonical";
    repo = "cloud-init";
    rev = "refs/tags/${version}";
    hash = "sha256-MsT5t2da79Eb9FlTLPr2893JcF0ujNnToJTCQRT1QEo=";
    hash = "sha256-w1UP7JIt/+6UlASB8kv2Lil+1sMTDIrADoYOT/WtaeE=";
  };

  patches = [ ./0001-add-nixos-support.patch ];
  patches = [ ./0001-add-nixos-support.patch ./0002-Add-Udhcpc-support.patch ];

  prePatch = ''
    substituteInPlace setup.py \
@@ -72,7 +74,7 @@ python3.pkgs.buildPythonApplication rec {
  ];

  makeWrapperArgs = [
    "--prefix PATH : ${lib.makeBinPath [ dmidecode cloud-utils.guest ]}/bin"
    "--prefix PATH : ${lib.makeBinPath [ dmidecode cloud-utils.guest busybox ]}/bin"
  ];

  disabledTests = [
@@ -82,6 +84,7 @@ python3.pkgs.buildPythonApplication rec {
    "test_path_env_gets_set_from_main"
    # tries to read from /etc/ca-certificates.conf while inside the sandbox
    "test_handler_ca_certs"
    "TestRemoveDefaultCaCerts"
    # Doesn't work in the sandbox
    "TestEphemeralDhcpNoNetworkSetup"
    "TestHasURLConnectivity"
@@ -112,13 +115,16 @@ python3.pkgs.buildPythonApplication rec {
    "cloudinit"
  ];

  passthru.tests = { inherit (nixosTests) cloud-init cloud-init-hostname; };
  passthru = {
    tests = { inherit (nixosTests) cloud-init cloud-init-hostname; };
    updateScript = gitUpdater { ignoredVersions = ".ubuntu.*"; };
  };

  meta = with lib; {
    homepage = "https://cloudinit.readthedocs.org";
    homepage = "https://github.com/canonical/cloud-init";
    description = "Provides configuration and customization of cloud instance";
    license = with licenses; [ asl20 gpl3Plus ];
    maintainers = with maintainers; [ illustris ];
    maintainers = with maintainers; [ illustris jfroche ];
    platforms = platforms.all;
  };
}