Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions cloudinit/net/dhcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
DHCLIENT_FALLBACK_LEASE_DIR = "/var/lib/dhclient"
# Match something.lease or something.leases
DHCLIENT_FALLBACK_LEASE_REGEX = r".+\.leases?$"
NMCLI = "nmcli"
UDHCPC_SCRIPT = """#!/bin/sh
log() {
echo "udhcpc[$PPID]" "$interface: $2"
Expand Down Expand Up @@ -147,6 +148,81 @@ def networkd_get_option_from_leases(keyname, leases_d=None):
return None


def run_nmcli(nmcli_opts: List[str]) -> str:
nmcli_path = subp.which(NMCLI)
if not nmcli_path:
raise NoDHCPLeaseMissingDhclientError()
Comment thread
ani-sinha marked this conversation as resolved.

command = [nmcli_path] + nmcli_opts
try:
out, _ = subp.subp(
command,
)
except subp.ProcessExecutionError as error:
LOG.debug(
"nmcli command exited with code: %s stderr: %r stdout: %r",
error.exit_code,
error.stderr,
error.stdout,
)
raise NoDHCPLeaseError from error
return out


def network_manager_load_leases(device: str) -> Dict[str, str]:
"""Return a dictionary of lease options obtained from NM cli"""

opts = ["--fields", "DHCP4", "device", "show", device]
nmcli_out = run_nmcli(opts)

content = []
for line in nmcli_out.splitlines():
line = line.partition(":")[2].strip()
content.append(line)

return dict(configobj.ConfigObj(content, list_values=False))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this processing works for single interface systems, it falls over on systems that have multiple devices because we have output like the following containing non-unique keys and each separate device is only separated by a blank newline

DHCP4.OPTION[1]:                        dhcp_client_identifier = 01:6c:ff:dd:aa>
DHCP4.OPTION[2]:                        dhcp_lease_time = 86400
DHCP4.OPTION[3]:                        dhcp_server_identifier = 192.168.1.1
DHCP4.OPTION[4]:                        domain_name_servers = 192.168.1.1
DHCP4.OPTION[5]:                        expiry = 1776277372
DHCP4.OPTION[6]:                        ip_address = 192.168.1.44
DHCP4.OPTION[7]:                        requested_broadcast_address = 1
DHCP4.OPTION[8]:                        requested_domain_name = 1
DHCP4.OPTION[9]:                        requested_domain_name_servers = 1
DHCP4.OPTION[10]:                       requested_domain_search = 1
DHCP4.OPTION[11]:                       requested_host_name = 1
DHCP4.OPTION[12]:                       requested_interface_mtu = 1
DHCP4.OPTION[13]:                       requested_ms_classless_static_routes = 1
DHCP4.OPTION[14]:                       requested_nis_domain = 1
DHCP4.OPTION[15]:                       requested_nis_servers = 1
DHCP4.OPTION[16]:                       requested_ntp_servers = 1
DHCP4.OPTION[17]:                       requested_rfc3442_classless_static_rout>
DHCP4.OPTION[18]:                       requested_root_path = 1
DHCP4.OPTION[19]:                       requested_routers = 1
DHCP4.OPTION[20]:                       requested_static_routes = 1
DHCP4.OPTION[21]:                       requested_subnet_mask = 1
DHCP4.OPTION[22]:                       requested_time_offset = 1
DHCP4.OPTION[23]:                       requested_wpad = 1
DHCP4.OPTION[24]:                       routers = 192.168.1.1
DHCP4.OPTION[25]:                       subnet_mask = 255.255.255.0

DHCP4.OPTION[1]:                        dhcp_client_identifier = 01:38:cc:nn:aa>
DHCP4.OPTION[2]:                        dhcp_lease_time = 86400
DHCP4.OPTION[3]:                        dhcp_server_identifier = 192.168.1.1
DHCP4.OPTION[4]:                        domain_name_servers = 192.168.1.1
DHCP4.OPTION[5]:                        expiry = 1776255526
DHCP4.OPTION[6]:                        ip_address = 192.168.1.17
DHCP4.OPTION[7]:                        requested_broadcast_address = 1
DHCP4.OPTION[8]:                        requested_domain_name = 1
DHCP4.OPTION[9]:                        requested_domain_name_servers = 1
DHCP4.OPTION[10]:                       requested_domain_search = 1
DHCP4.OPTION[11]:                       requested_host_name = 1
DHCP4.OPTION[12]:                       requested_interface_mtu = 1
DHCP4.OPTION[13]:                       requested_ms_classless_static_routes = 1
DHCP4.OPTION[14]:                       requested_nis_domain = 1
DHCP4.OPTION[15]:                       requested_nis_servers = 1
DHCP4.OPTION[16]:                       requested_ntp_servers = 1
DHCP4.OPTION[17]:                       requested_rfc3442_classless_static_rout>
DHCP4.OPTION[18]:                       requested_root_path = 1
DHCP4.OPTION[19]:                       requested_routers = 1
DHCP4.OPTION[20]:                       requested_static_routes = 1
DHCP4.OPTION[21]:                       requested_subnet_mask = 1
DHCP4.OPTION[22]:                       requested_time_offset = 1
DHCP4.OPTION[23]:                       requested_wpad = 1
DHCP4.OPTION[24]:                       routers = 192.168.1.1
DHCP4.OPTION[25]:                       subnet_mask = 255.255.255.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This results in ConfgObj failures due to being unable to process the content:

Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "./cloud-init/cloudinit/net/dhcp.py", line 187, in network_manager_load_leases
    return dict(configobj.ConfigObj(content, list_values=False))
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/configobj/__init__.py", line 1228, in __init__
    self._load(infile, configspec)
  File "/usr/lib/python3/dist-packages/configobj/__init__.py", line 1317, in _load
    raise error
configobj.ConfigObjError: Parsing failed with several errors.
First error at line 27.



def find_correct_device_nmcli(device: Optional[str]) -> Optional[str]:
"""If device is specified, return the value of the lease parameter
specified by 'keyname' for that device. Else return the lease value for
the first connected device as returned by 'nmcli'"""

device_list_opts = ["--terse", "device"]
nmcli_out = run_nmcli(device_list_opts)

for line in nmcli_out.splitlines():
if line == "":
continue
dev_name, _, state, _ = line.split(":", 3)

# skip devices that are not connected
if state != "connected":
continue
# skip loopback
if dev_name == "lo":
continue
# if no device name is passed, use the first one found.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know what order these will be printed in?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we know what order these will be printed in?

From what I have seen, its mostly active devices, then loopback and then deactivated devices. But I am not sure if this order is true for all versions of nmcli across all distributions.

if device is None:
return dev_name
# else use the name of the device passed.
elif device and device.lower() == dev_name.lower():
return dev_name
return None


def network_manager_get_option_from_leases(
keyname: str, device: Optional[str] = None
) -> Optional[str]:
Comment thread
ani-sinha marked this conversation as resolved.

leases = None
dev = find_correct_device_nmcli(device)
if dev:
leases = network_manager_load_leases(dev)

return leases.get(keyname) if leases else None


class DhcpClient(abc.ABC):
client_name = ""
timeout = 10
Expand Down
24 changes: 23 additions & 1 deletion cloudinit/sources/DataSourceCloudStack.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ def _get_domainname(self):
"""Try obtaining a "domain-name" DHCP lease parameter:
- From systemd-networkd lease (case-insensitive)
- From ISC dhclient
- From network manager dhcp client
- From dhcpcd (ephemeral)
- Return empty string if not found (non-fatal)
"""
Expand All @@ -113,7 +114,19 @@ def _get_domainname(self):

LOG.debug(
"Could not obtain FQDN from ISC dhclient leases. Falling back to "
"%s",
"Network Manager leases"
)
with suppress(
dhcp.NoDHCPLeaseMissingDhclientError, dhcp.NoDHCPLeaseError
):
domain_name = dhcp.network_manager_get_option_from_leases(
"domain_name"
)
if domain_name:
return domain_name.strip()

Comment thread
ani-sinha marked this conversation as resolved.
LOG.debug(
"Could not obtain FQDN from NM leases. Falling back to %s",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this inserted before the client rather than after?

Copy link
Copy Markdown
Contributor Author

@ani-sinha ani-sinha Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this inserted before the client rather than after?

You lost me here. Its how the debug logs are added for others like IscDHClient etc as well.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was referring to fallback order. This adds NetworkManager before the dhcp client.

Copy link
Copy Markdown
Contributor Author

@ani-sinha ani-sinha Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was referring to fallback order. This adds NetworkManager before the dhcp client.

This is correct. This aligns with other dhcp clients in the function. Default client is dhcpcd but on RHEL, its network manager that primarily gives out the leases. We do not have full support for network manager as a client yet. There is a plan to implement it but won't happen anytime soon.

self.distro.dhcp_client.client_name,
)
try:
Expand Down Expand Up @@ -338,6 +351,15 @@ def get_vr_address(distro):
LOG.debug("Found SERVER_ADDRESS '%s' via dhclient", latest_address)
return latest_address

# try network manager DHCP lease information
with suppress(dhcp.NoDHCPLeaseMissingDhclientError, dhcp.NoDHCPLeaseError):
latest_address = dhcp.network_manager_get_option_from_leases(
"dhcp_server_identifier"
)
if latest_address:
LOG.debug("Found SERVER_ADDRESS '%s' via nmcli", latest_address)
return latest_address

with suppress(FileNotFoundError):
latest_lease = distro.dhcp_client.get_newest_lease(
distro.fallback_interface
Expand Down
112 changes: 112 additions & 0 deletions tests/unittests/net/test_dhcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
NoDHCPLeaseInterfaceError,
NoDHCPLeaseMissingDhclientError,
Udhcpc,
find_correct_device_nmcli,
maybe_perform_dhcp_discovery,
network_manager_load_leases,
networkd_load_leases,
run_nmcli,
)
from cloudinit.net.ephemeral import EphemeralDHCPv4
from cloudinit.subp import SubpResult
Expand Down Expand Up @@ -1392,3 +1395,112 @@ def test_none_and_missing_fallback(self):
with pytest.raises(NoDHCPLeaseInterfaceError):
distro = mock.Mock(fallback_interface=None)
maybe_perform_dhcp_discovery(distro, None)


class TestNMDhcpLeases:
Comment thread
ani-sinha marked this conversation as resolved.
def test_find_correct_connected_device_first_match(self):
with mock.patch(
"cloudinit.net.dhcp.run_nmcli",
return_value=dedent(
"""
ens150:ethernet:unavailable:
p2p-dev-wlp:wifi-p2p:disconnected:
ens160:ethernet:connected:Wired connection 2
ens256:ethernet:connected:Wired connection 3
lo:loopback:connected (externally):lo
"""
),
):
ret = find_correct_device_nmcli(None)
assert ret == "ens160"

def test_find_correct_connected_device_no_match(self):
with mock.patch(
"cloudinit.net.dhcp.run_nmcli",
return_value=dedent(
"""
ens160:ethernet:connected:Wired connection 1
ens256:ethernet:connected:Wired connection 2
lo:loopback:connected (externally):lo
"""
),
):
ret = find_correct_device_nmcli("ens250")
assert ret is None

def test_find_correct_connected_device_second_match(self):
with mock.patch(
"cloudinit.net.dhcp.run_nmcli",
return_value=dedent(
"""
ens160:ethernet:connected:Wired connection 1
ens256:ethernet:connected:Wired connection 2
lo:loopback:connected (externally):lo
"""
),
):
ret = find_correct_device_nmcli("ens256")
assert ret == "ens256"

def test_network_manager_load_leases(self):
expected_return = {
"broadcast_address": "172.16.127.255",
"dhcp_client_identifier": "01:00:0c:29:bf:c5:56",
"dhcp_lease_time": "1800",
"dhcp_server_identifier": "172.16.127.254",
}
with mock.patch(
"cloudinit.net.dhcp.run_nmcli",
return_value=dedent(
"""
DHCP4.OPTION[1]: broadcast_address = 172.16.127.255
DHCP4.OPTION[2]: dhcp_client_identifier = 01:00:0c:29:bf:c5:56
DHCP4.OPTION[3]: dhcp_lease_time = 1800
DHCP4.OPTION[4]: dhcp_server_identifier = 172.16.127.254
"""
),
):
ret = network_manager_load_leases("ens10")
assert ret == expected_return

@mock.patch("cloudinit.net.dhcp.subp.which", return_value="/usr/bin/nmcli")
@mock.patch("cloudinit.net.dhcp.subp.subp")
def test_run_nmcli(self, m_subp, m_which):
expected_return = dedent(
"""
DHCP4.OPTION[1]: broadcast_address = 172.16.127.255
DHCP4.OPTION[2]: dhcp_client_identifier = 01:00:0c:29:bf:c5:56
DHCP4.OPTION[3]: dhcp_lease_time = 1800
DHCP4.OPTION[4]: dhcp_server_identifier = 172.16.127.254
"""
)
m_subp.return_value = (expected_return, "")
ret = run_nmcli(["show"])
assert ret == expected_return

@mock.patch("cloudinit.net.dhcp.subp.which", return_value=None)
def test_run_nmcli_missing_nmcli(self, m_which):
"""verify that absence of nmcli binary can result in
raising NoDHCPLeaseMissingDhclientError"""

with pytest.raises(NoDHCPLeaseMissingDhclientError):
run_nmcli(["show"])

@mock.patch("cloudinit.net.dhcp.subp.which", return_value="/usr/bin/nmcli")
@mock.patch("cloudinit.net.dhcp.subp.subp")
def test_run_nmcli_subp_err(self, m_subp, m_which):
"""verify that when subp.ProcessExecutionError is raised while
running nmcli, it results in run_nmcli raising NoDHCPLeaseError"""

m_subp.side_effect = subp.ProcessExecutionError(exit_code=-5)

with pytest.raises(NoDHCPLeaseError):
run_nmcli(["--fields", "all", "device", "show"])

m_subp.assert_has_calls(
[
mock.call(
["/usr/bin/nmcli", "--fields", "all", "device", "show"],
),
]
)
Loading
Loading