1diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py
2index a9a1c980..2d83089b 100644
3--- a/cloudinit/net/dhcp.py
4+++ b/cloudinit/net/dhcp.py
5@@ -14,12 +14,48 @@ from io import StringIO
6
7 import configobj
8
9-from cloudinit import subp, util
10+from cloudinit import subp, util, temp_utils
11 from cloudinit.net import find_fallback_nic, get_devicelist
12
13 LOG = logging.getLogger(__name__)
14
15 NETWORKD_LEASES_DIR = "/run/systemd/netif/leases"
16+UDHCPC_SCRIPT = """#!/bin/sh
17+log() {
18+ echo "udhcpc[$PPID]" "$interface: $2"
19+}
20+
21+[ -z "$1" ] && echo "Error: should be called from udhcpc" && exit 1
22+
23+case $1 in
24+ bound|renew)
25+ cat <<JSON > "$LEASE_FILE"
26+{
27+ "interface": "$interface",
28+ "fixed-address": "$ip",
29+ "subnet-mask": "$subnet",
30+ "routers": "${router%% *}",
31+ "static_routes" : "${staticroutes}"
32+}
33+JSON
34+ ;;
35+
36+ deconfig)
37+ log err "Not supported"
38+ exit 1
39+ ;;
40+
41+ leasefail | nak)
42+ log err "configuration failed: $1: $message"
43+ exit 1
44+ ;;
45+
46+ *)
47+ echo "$0: Unknown udhcpc command: $1" >&2
48+ exit 1
49+ ;;
50+esac
51+"""
52
53
54 class NoDHCPLeaseError(Exception):
55@@ -43,12 +79,14 @@ class NoDHCPLeaseMissingDhclientError(NoDHCPLeaseError):
56
57
58 def maybe_perform_dhcp_discovery(nic=None, dhcp_log_func=None, tmp_dir=None):
59- """Perform dhcp discovery if nic valid and dhclient command exists.
60+ """Perform dhcp discovery if nic valid and dhclient or udhcpc command
61+ exists.
62
63 If the nic is invalid or undiscoverable or dhclient command is not found,
64 skip dhcp_discovery and return an empty dict.
65
66- @param nic: Name of the network interface we want to run dhclient on.
67+ @param nic: Name of the network interface we want to run the dhcp client
68+ on.
69 @param dhcp_log_func: A callable accepting the dhclient output and error
70 streams.
71 @param tmp_dir: Tmp dir with exec permissions.
72@@ -66,11 +104,16 @@ def maybe_perform_dhcp_discovery(nic=None, dhcp_log_func=None, tmp_dir=None):
73 "Skip dhcp_discovery: nic %s not found in get_devicelist.", nic
74 )
75 raise NoDHCPLeaseInterfaceError()
76+ udhcpc_path = subp.which("udhcpc")
77+ if udhcpc_path:
78+ return dhcp_udhcpc_discovery(udhcpc_path, nic, dhcp_log_func)
79 dhclient_path = subp.which("dhclient")
80- if not dhclient_path:
81- LOG.debug("Skip dhclient configuration: No dhclient command found.")
82- raise NoDHCPLeaseMissingDhclientError()
83- return dhcp_discovery(dhclient_path, nic, dhcp_log_func)
84+ if dhclient_path:
85+ return dhcp_discovery(dhclient_path, nic, dhcp_log_func)
86+ LOG.debug(
87+ "Skip dhclient configuration: No dhclient or udhcpc command found."
88+ )
89+ raise NoDHCPLeaseMissingDhclientError()
90
91
92 def parse_dhcp_lease_file(lease_file):
93@@ -107,6 +150,61 @@ def parse_dhcp_lease_file(lease_file):
94 return dhcp_leases
95
96
97+def dhcp_udhcpc_discovery(udhcpc_cmd_path, interface, dhcp_log_func=None):
98+ """Run udhcpc on the interface without scripts or filesystem artifacts.
99+
100+ @param udhcpc_cmd_path: Full path to the udhcpc used.
101+ @param interface: Name of the network interface on which to dhclient.
102+ @param dhcp_log_func: A callable accepting the dhclient output and error
103+ streams.
104+
105+ @return: A list of dicts of representing the dhcp leases parsed from the
106+ dhclient.lease file or empty list.
107+ """
108+ LOG.debug("Performing a dhcp discovery on %s", interface)
109+
110+ tmp_dir = temp_utils.get_tmp_ancestor(needs_exe=True)
111+ lease_file = os.path.join(tmp_dir, interface + ".lease.json")
112+ with contextlib.suppress(FileNotFoundError):
113+ os.remove(lease_file)
114+
115+ # udhcpc needs the interface up to send initial discovery packets.
116+ # Generally dhclient relies on dhclient-script PREINIT action to bring the
117+ # link up before attempting discovery. Since we are using -sf /bin/true,
118+ # we need to do that "link up" ourselves first.
119+ subp.subp(["ip", "link", "set", "dev", interface, "up"], capture=True)
120+ udhcpc_script = os.path.join(tmp_dir, "udhcpc_script")
121+ util.write_file(udhcpc_script, UDHCPC_SCRIPT, 0o755)
122+ cmd = [
123+ udhcpc_cmd_path,
124+ "-O",
125+ "staticroutes",
126+ "-i",
127+ interface,
128+ "-s",
129+ udhcpc_script,
130+ "-n", # Exit if lease is not obtained
131+ "-q", # Exit after obtaining lease
132+ "-f", # Run in foreground
133+ "-v",
134+ ]
135+
136+ out, err = subp.subp(
137+ cmd, update_env={"LEASE_FILE": lease_file}, capture=True
138+ )
139+
140+ if dhcp_log_func is not None:
141+ dhcp_log_func(out, err)
142+ lease_json = util.load_json(util.load_file(lease_file))
143+ static_routes = lease_json["static_routes"].split()
144+ if static_routes:
145+ # format: dest1/mask gw1 ... destn/mask gwn
146+ lease_json["static_routes"] = [
147+ i for i in zip(static_routes[::2], static_routes[1::2])
148+ ]
149+ return [lease_json]
150+
151+
152 def dhcp_discovery(dhclient_cmd_path, interface, dhcp_log_func=None):
153 """Run dhclient on the interface without scripts or filesystem artifacts.
154
155diff --git a/tests/unittests/net/test_dhcp.py b/tests/unittests/net/test_dhcp.py
156index 40340553..8913cf65 100644
157--- a/tests/unittests/net/test_dhcp.py
158+++ b/tests/unittests/net/test_dhcp.py
159@@ -12,6 +12,7 @@ from cloudinit.net.dhcp import (
160 NoDHCPLeaseError,
161 NoDHCPLeaseInterfaceError,
162 NoDHCPLeaseMissingDhclientError,
163+ dhcp_udhcpc_discovery,
164 dhcp_discovery,
165 maybe_perform_dhcp_discovery,
166 networkd_load_leases,
167@@ -334,6 +335,43 @@ class TestDHCPParseStaticRoutes(CiTestCase):
168 )
169
170
171+class TestUDHCPCDiscoveryClean(CiTestCase):
172+ maxDiff = None
173+
174+ @mock.patch("cloudinit.net.dhcp.os.remove")
175+ @mock.patch("cloudinit.net.dhcp.subp.subp")
176+ @mock.patch("cloudinit.util.load_json")
177+ @mock.patch("cloudinit.util.load_file")
178+ @mock.patch("cloudinit.util.write_file")
179+ def test_udhcpc_discovery(
180+ self, m_write_file, m_load_file, m_loadjson, m_subp, m_remove
181+ ):
182+ """dhcp_discovery waits for the presence of pidfile and dhcp.leases."""
183+ m_subp.return_value = ("", "")
184+ m_loadjson.return_value = {
185+ "interface": "eth9",
186+ "fixed-address": "192.168.2.74",
187+ "subnet-mask": "255.255.255.0",
188+ "routers": "192.168.2.1",
189+ "static_routes": "10.240.0.1/32 0.0.0.0 0.0.0.0/0 10.240.0.1",
190+ }
191+ self.assertEqual(
192+ [
193+ {
194+ "fixed-address": "192.168.2.74",
195+ "interface": "eth9",
196+ "routers": "192.168.2.1",
197+ "static_routes": [
198+ ("10.240.0.1/32", "0.0.0.0"),
199+ ("0.0.0.0/0", "10.240.0.1"),
200+ ],
201+ "subnet-mask": "255.255.255.0",
202+ }
203+ ],
204+ dhcp_udhcpc_discovery("/sbin/udhcpc", "eth9"),
205+ )
206+
207+
208 class TestDHCPDiscoveryClean(CiTestCase):
209 with_logs = True
210
211@@ -372,7 +410,7 @@ class TestDHCPDiscoveryClean(CiTestCase):
212 maybe_perform_dhcp_discovery()
213
214 self.assertIn(
215- "Skip dhclient configuration: No dhclient command found.",
216+ "Skip dhclient configuration: No dhclient or udhcpc command found.",
217 self.logs.getvalue(),
218 )
219
220--
2212.38.4
222