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
This commit is contained in:
Jean-François Roche 2023-04-25 13:33:29 +02:00 committed by GitHub
parent c1e467b13c
commit 25671114cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 240 additions and 11 deletions

View file

@ -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
;

View file

@ -1,11 +1,11 @@
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"],
}

View file

@ -0,0 +1,222 @@
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

View file

@ -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;
};
}