From c82ace920a743c6e6797536416018d9680b8fa7e Mon Sep 17 00:00:00 2001
From: Chris Patterson <cpatterson@microsoft.com>
Date: Wed, 29 Mar 2023 17:30:13 -0400
Subject: [PATCH] net/dhcp: catch dhclient failures and raise NoDHCPLeaseError
 (#2083)

Some variants of dhclient will exit with non-zero codes on lease
failure. For example, on RHEL 8.7:
```
[cpatterson@test-rhel87 ~]$ sudo /usr/sbin/dhclient -1 -v -lf
/tmp/my.lease -pf /tmp/my.pid bridge2nowhere -sf /bin/
true
Internet Systems Consortium DHCP Client 4.3.6
Copyright 2004-2017 Internet Systems Consortium.
All rights reserved.
For info, please visit https://www.isc.org/software/dhcp/

Listening on LPF/bridge2nowhere/42:ef:d5:38:1d:19
Sending on   LPF/bridge2nowhere/42:ef:d5:38:1d:19
Sending on   Socket/fallback
Created duid "\000\004E<\225X\232\304J\337\243\026T\324\243O\270\177".
DHCPDISCOVER on bridge2nowhere to 255.255.255.255 port 67 interval 4
(xid=0x777bc142)
DHCPDISCOVER on bridge2nowhere to 255.255.255.255 port 67 interval 7
(xid=0x777bc142)
DHCPDISCOVER on bridge2nowhere to 255.255.255.255 port 67 interval 13
(xid=0x777bc142)
DHCPDISCOVER on bridge2nowhere to 255.255.255.255 port 67 interval 6
(xid=0x777bc142)
No DHCPOFFERS received.
Unable to obtain a lease on first try.  Exiting.

[cpatterson@test-rhel87 ~]$ echo $?
2
```

This results in an unhandled subp.ProcessExecutionError exception.
Catch these failures and re-raise as NoDHCPLeaseError.

Signed-off-by: Chris Patterson <cpatterson@microsoft.com>
---
 cloudinit/net/dhcp.py            | 11 ++++++++++-
 cloudinit/net/tests/test_dhcp.py | 23 +++++++++++++++++++++--
 2 files changed, 31 insertions(+), 3 deletions(-)

diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py
index 3f4b041..e5f36e1 100644
--- a/cloudinit/net/dhcp.py
+++ b/cloudinit/net/dhcp.py
@@ -239,7 +239,16 @@ def dhcp_discovery(dhclient_cmd_path, interface, cleandir, dhcp_log_func=None):
     subp.subp(['ip', 'link', 'set', 'dev', interface, 'up'], capture=True)
     cmd = [sandbox_dhclient_cmd, '-1', '-v', '-lf', lease_file,
            '-pf', pid_file, interface, '-sf', '/bin/true']
-    out, err = subp.subp(cmd, capture=True)
+    try:
+        out, err = subp.subp(cmd, capture=True)
+    except subp.ProcessExecutionError as error:
+        LOG.debug(
+            "dhclient exited with code: %s stderr: %r stdout: %r",
+            error.exit_code,
+            error.stderr,
+            error.stdout,
+        )
+        raise NoDHCPLeaseError from error
 
     # Wait for pid file and lease file to appear, and for the process
     # named by the pid file to daemonize (have pid 1 as its parent). If we
diff --git a/cloudinit/net/tests/test_dhcp.py b/cloudinit/net/tests/test_dhcp.py
index 28b4ecf..de4b461 100644
--- a/cloudinit/net/tests/test_dhcp.py
+++ b/cloudinit/net/tests/test_dhcp.py
@@ -3,14 +3,15 @@
 import httpretty
 import os
 import signal
+import pytest
 from textwrap import dedent
 
 import cloudinit.net as net
 from cloudinit.net.dhcp import (
-    InvalidDHCPLeaseFileError, maybe_perform_dhcp_discovery,
+    InvalidDHCPLeaseFileError, NoDHCPLeaseError, maybe_perform_dhcp_discovery,
     parse_dhcp_lease_file, dhcp_discovery, networkd_load_leases,
     parse_static_routes)
-from cloudinit.util import ensure_file, write_file
+from cloudinit.util import ensure_file, subp, write_file
 from cloudinit.tests.helpers import (
     CiTestCase, HttprettyTestCase, mock, populate_dir, wrap_and_call)
 
@@ -268,6 +269,24 @@ class TestDHCPDiscoveryClean(CiTestCase):
             'Skip dhcp_discovery: Unable to find fallback nic.',
             self.logs.getvalue())
 
+    @mock.patch("cloudinit.net.dhcp.find_fallback_nic", return_value="eth9")
+    @mock.patch("cloudinit.net.dhcp.os.remove")
+    @mock.patch("cloudinit.net.dhcp.subp.subp")
+    def test_dhclient_exits_with_error(self, m_subp, m_remove, m_fallback):
+        """Log and do nothing when nic is absent and no fallback is found."""
+        m_subp.side_effect = [
+            ("", ""),
+            subp.ProcessExecutionError(exit_code=-5),
+        ]
+
+        with pytest.raises(NoDHCPLeaseError):
+            maybe_perform_dhcp_discovery()
+
+        self.assertIn(
+            "dhclient exited with code: -5",
+            self.logs.getvalue(),
+        )
+
     def test_provided_nic_does_not_exist(self):
         """When the provided nic doesn't exist, log a message and no-op."""
         self.assertEqual([], maybe_perform_dhcp_discovery('idontexist'))
-- 
2.33.0


