From d910ecd15de642d73a36e935704e54370f93c45b Mon Sep 17 00:00:00 2001 From: asakkurr Date: Mon, 12 Nov 2018 17:16:09 +0000 Subject: azure: fix regression introduced when persisting ephemeral dhcp lease In commitish 9073951 azure datasource tried to leverage stale DHCP information obtained from EphemeralDHCPv4 context manager to report updated provisioning status to the fabric earlier in the boot process. Unfortunately the stale ephemeral network configuration had already been torn down in preparation to bring up IMDS network config so the report attempt failed on timeout. This branch introduces obtain_lease and clean_network public methods on EphemeralDHCPv4 to allow for setup and teardown of ephemeral network configuration without using a context manager. Azure datasource now uses this to persist ephemeral network configuration across multiple contexts during provisioning to avoid multiple DHCP roundtrips. --- cloudinit/net/dhcp.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) (limited to 'cloudinit/net/dhcp.py') diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py index 12cf5097..bdc5799f 100644 --- a/cloudinit/net/dhcp.py +++ b/cloudinit/net/dhcp.py @@ -40,34 +40,56 @@ class EphemeralDHCPv4(object): def __init__(self, iface=None): self.iface = iface self._ephipv4 = None + self.lease = None def __enter__(self): + """Setup sandboxed dhcp context.""" + return self.obtain_lease() + + def __exit__(self, excp_type, excp_value, excp_traceback): + """Teardown sandboxed dhcp context.""" + self.clean_network() + + def clean_network(self): + """Exit _ephipv4 context to teardown of ip configuration performed.""" + if self.lease: + self.lease = None + if not self._ephipv4: + return + self._ephipv4.__exit__(None, None, None) + + def obtain_lease(self): + """Perform dhcp discovery in a sandboxed environment if possible. + + @return: A dict representing dhcp options on the most recent lease + obtained from the dhclient discovery if run, otherwise an error + is raised. + + @raises: NoDHCPLeaseError if no leases could be obtained. + """ + if self.lease: + return self.lease try: leases = maybe_perform_dhcp_discovery(self.iface) except InvalidDHCPLeaseFileError: raise NoDHCPLeaseError() if not leases: raise NoDHCPLeaseError() - lease = leases[-1] + self.lease = leases[-1] LOG.debug("Received dhcp lease on %s for %s/%s", - lease['interface'], lease['fixed-address'], - lease['subnet-mask']) + self.lease['interface'], self.lease['fixed-address'], + self.lease['subnet-mask']) nmap = {'interface': 'interface', 'ip': 'fixed-address', 'prefix_or_mask': 'subnet-mask', 'broadcast': 'broadcast-address', 'router': 'routers'} - kwargs = dict([(k, lease.get(v)) for k, v in nmap.items()]) + kwargs = dict([(k, self.lease.get(v)) for k, v in nmap.items()]) if not kwargs['broadcast']: kwargs['broadcast'] = bcip(kwargs['prefix_or_mask'], kwargs['ip']) ephipv4 = EphemeralIPv4Network(**kwargs) ephipv4.__enter__() self._ephipv4 = ephipv4 - return lease - - def __exit__(self, excp_type, excp_value, excp_traceback): - if not self._ephipv4: - return - self._ephipv4.__exit__(excp_type, excp_value, excp_traceback) + return self.lease def maybe_perform_dhcp_discovery(nic=None): -- cgit v1.2.3 From ef0611a51a98a273cfa37b0daeb3e9d151888b88 Mon Sep 17 00:00:00 2001 From: Chad Smith Date: Mon, 26 Nov 2018 17:37:31 +0000 Subject: net: Ephemeral*Network: add connectivity check via URL We add a new Optional parameter: connectivity_url This is used in __enter__ to verify if a connection already exists. If it does exist, no operations are performed. --- cloudinit/net/__init__.py | 36 ++++++++++++++++++++++++++-- cloudinit/net/dhcp.py | 17 ++++++++++--- cloudinit/net/tests/test_dhcp.py | 36 +++++++++++++++++++++++++++- cloudinit/net/tests/test_init.py | 52 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 134 insertions(+), 7 deletions(-) (limited to 'cloudinit/net/dhcp.py') diff --git a/cloudinit/net/__init__.py b/cloudinit/net/__init__.py index ad98a595..3642fb1f 100644 --- a/cloudinit/net/__init__.py +++ b/cloudinit/net/__init__.py @@ -12,6 +12,7 @@ import re from cloudinit.net.network_state import mask_to_net_prefix from cloudinit import util +from cloudinit.url_helper import UrlError, readurl LOG = logging.getLogger(__name__) SYS_CLASS_NET = "/sys/class/net/" @@ -647,16 +648,36 @@ def get_ib_hwaddrs_by_interface(): return ret +def has_url_connectivity(url): + """Return true when the instance has access to the provided URL + + Logs a warning if url is not the expected format. + """ + if not any([url.startswith('http://'), url.startswith('https://')]): + LOG.warning( + "Ignoring connectivity check. Expected URL beginning with http*://" + " received '%s'", url) + return False + try: + readurl(url, timeout=5) + except UrlError: + return False + return True + + class EphemeralIPv4Network(object): """Context manager which sets up temporary static network configuration. - No operations are performed if the provided interface is already connected. + No operations are performed if the provided interface already has the + specified configuration. + This can be verified with the connectivity_url. If unconnected, bring up the interface with valid ip, prefix and broadcast. If router is provided setup a default route for that interface. Upon context exit, clean up the interface leaving no configuration behind. """ - def __init__(self, interface, ip, prefix_or_mask, broadcast, router=None): + def __init__(self, interface, ip, prefix_or_mask, broadcast, router=None, + connectivity_url=None): """Setup context manager and validate call signature. @param interface: Name of the network interface to bring up. @@ -665,6 +686,8 @@ class EphemeralIPv4Network(object): prefix. @param broadcast: Broadcast address for the IPv4 network. @param router: Optionally the default gateway IP. + @param connectivity_url: Optionally, a URL to verify if a usable + connection already exists. """ if not all([interface, ip, prefix_or_mask, broadcast]): raise ValueError( @@ -675,6 +698,8 @@ class EphemeralIPv4Network(object): except ValueError as e: raise ValueError( 'Cannot setup network: {0}'.format(e)) + + self.connectivity_url = connectivity_url self.interface = interface self.ip = ip self.broadcast = broadcast @@ -683,6 +708,13 @@ class EphemeralIPv4Network(object): def __enter__(self): """Perform ephemeral network setup if interface is not connected.""" + if self.connectivity_url: + if has_url_connectivity(self.connectivity_url): + LOG.debug( + 'Skip ephemeral network setup, instance has connectivity' + ' to %s', self.connectivity_url) + return + self._bringup_device() if self.router: self._bringup_router() diff --git a/cloudinit/net/dhcp.py b/cloudinit/net/dhcp.py index bdc5799f..0db991db 100644 --- a/cloudinit/net/dhcp.py +++ b/cloudinit/net/dhcp.py @@ -11,7 +11,8 @@ import re import signal from cloudinit.net import ( - EphemeralIPv4Network, find_fallback_nic, get_devicelist) + EphemeralIPv4Network, find_fallback_nic, get_devicelist, + has_url_connectivity) from cloudinit.net.network_state import mask_and_ipv4_to_bcast_addr as bcip from cloudinit import temp_utils from cloudinit import util @@ -37,13 +38,21 @@ class NoDHCPLeaseError(Exception): class EphemeralDHCPv4(object): - def __init__(self, iface=None): + def __init__(self, iface=None, connectivity_url=None): self.iface = iface self._ephipv4 = None self.lease = None + self.connectivity_url = connectivity_url def __enter__(self): - """Setup sandboxed dhcp context.""" + """Setup sandboxed dhcp context, unless connectivity_url can already be + reached.""" + if self.connectivity_url: + if has_url_connectivity(self.connectivity_url): + LOG.debug( + 'Skip ephemeral DHCP setup, instance has connectivity' + ' to %s', self.connectivity_url) + return return self.obtain_lease() def __exit__(self, excp_type, excp_value, excp_traceback): @@ -86,6 +95,8 @@ class EphemeralDHCPv4(object): kwargs = dict([(k, self.lease.get(v)) for k, v in nmap.items()]) if not kwargs['broadcast']: kwargs['broadcast'] = bcip(kwargs['prefix_or_mask'], kwargs['ip']) + if self.connectivity_url: + kwargs['connectivity_url'] = self.connectivity_url ephipv4 = EphemeralIPv4Network(**kwargs) ephipv4.__enter__() self._ephipv4 = ephipv4 diff --git a/cloudinit/net/tests/test_dhcp.py b/cloudinit/net/tests/test_dhcp.py index db25b6f2..cd3e7328 100644 --- a/cloudinit/net/tests/test_dhcp.py +++ b/cloudinit/net/tests/test_dhcp.py @@ -1,15 +1,17 @@ # This file is part of cloud-init. See LICENSE file for license information. +import httpretty import os import signal from textwrap import dedent +import cloudinit.net as net from cloudinit.net.dhcp import ( InvalidDHCPLeaseFileError, maybe_perform_dhcp_discovery, parse_dhcp_lease_file, dhcp_discovery, networkd_load_leases) from cloudinit.util import ensure_file, write_file from cloudinit.tests.helpers import ( - CiTestCase, mock, populate_dir, wrap_and_call) + CiTestCase, HttprettyTestCase, mock, populate_dir, wrap_and_call) class TestParseDHCPLeasesFile(CiTestCase): @@ -321,3 +323,35 @@ class TestSystemdParseLeases(CiTestCase): '9': self.lxd_lease}) self.assertEqual({'1': self.azure_parsed, '9': self.lxd_parsed}, networkd_load_leases(self.lease_d)) + + +class TestEphemeralDhcpNoNetworkSetup(HttprettyTestCase): + + @mock.patch('cloudinit.net.dhcp.maybe_perform_dhcp_discovery') + def test_ephemeral_dhcp_no_network_if_url_connectivity(self, m_dhcp): + """No EphemeralDhcp4 network setup when connectivity_url succeeds.""" + url = 'http://example.org/index.html' + + httpretty.register_uri(httpretty.GET, url) + with net.dhcp.EphemeralDHCPv4(connectivity_url=url) as lease: + self.assertIsNone(lease) + # Ensure that no teardown happens: + m_dhcp.assert_not_called() + + @mock.patch('cloudinit.net.dhcp.util.subp') + @mock.patch('cloudinit.net.dhcp.maybe_perform_dhcp_discovery') + def test_ephemeral_dhcp_setup_network_if_url_connectivity( + self, m_dhcp, m_subp): + """No EphemeralDhcp4 network setup when connectivity_url succeeds.""" + url = 'http://example.org/index.html' + fake_lease = { + 'interface': 'eth9', 'fixed-address': '192.168.2.2', + 'subnet-mask': '255.255.0.0'} + m_dhcp.return_value = [fake_lease] + m_subp.return_value = ('', '') + + httpretty.register_uri(httpretty.GET, url, body={}, status=404) + with net.dhcp.EphemeralDHCPv4(connectivity_url=url) as lease: + self.assertEqual(fake_lease, lease) + # Ensure that dhcp discovery occurs + m_dhcp.called_once_with() diff --git a/cloudinit/net/tests/test_init.py b/cloudinit/net/tests/test_init.py index 58e0a591..f55c31e8 100644 --- a/cloudinit/net/tests/test_init.py +++ b/cloudinit/net/tests/test_init.py @@ -2,14 +2,16 @@ import copy import errno +import httpretty import mock import os +import requests import textwrap import yaml import cloudinit.net as net from cloudinit.util import ensure_file, write_file, ProcessExecutionError -from cloudinit.tests.helpers import CiTestCase +from cloudinit.tests.helpers import CiTestCase, HttprettyTestCase class TestSysDevPath(CiTestCase): @@ -458,6 +460,22 @@ class TestEphemeralIPV4Network(CiTestCase): self.assertEqual(expected_setup_calls, m_subp.call_args_list) m_subp.assert_has_calls(expected_teardown_calls) + @mock.patch('cloudinit.net.readurl') + def test_ephemeral_ipv4_no_network_if_url_connectivity( + self, m_readurl, m_subp): + """No network setup is performed if we can successfully connect to + connectivity_url.""" + params = { + 'interface': 'eth0', 'ip': '192.168.2.2', + 'prefix_or_mask': '255.255.255.0', 'broadcast': '192.168.2.255', + 'connectivity_url': 'http://example.org/index.html'} + + with net.EphemeralIPv4Network(**params): + self.assertEqual([mock.call('http://example.org/index.html', + timeout=5)], m_readurl.call_args_list) + # Ensure that no teardown happens: + m_subp.assert_has_calls([]) + def test_ephemeral_ipv4_network_noop_when_configured(self, m_subp): """EphemeralIPv4Network handles exception when address is setup. @@ -619,3 +637,35 @@ class TestApplyNetworkCfgNames(CiTestCase): def test_apply_v2_renames_raises_runtime_error_on_unknown_version(self): with self.assertRaises(RuntimeError): net.apply_network_config_names(yaml.load("version: 3")) + + +class TestHasURLConnectivity(HttprettyTestCase): + + def setUp(self): + super(TestHasURLConnectivity, self).setUp() + self.url = 'http://fake/' + self.kwargs = {'allow_redirects': True, 'timeout': 5.0} + + @mock.patch('cloudinit.net.readurl') + def test_url_timeout_on_connectivity_check(self, m_readurl): + """A timeout of 5 seconds is provided when reading a url.""" + self.assertTrue( + net.has_url_connectivity(self.url), 'Expected True on url connect') + + def test_true_on_url_connectivity_success(self): + httpretty.register_uri(httpretty.GET, self.url) + self.assertTrue( + net.has_url_connectivity(self.url), 'Expected True on url connect') + + @mock.patch('requests.Session.request') + def test_true_on_url_connectivity_timeout(self, m_request): + """A timeout raised accessing the url will return False.""" + m_request.side_effect = requests.Timeout('Fake Connection Timeout') + self.assertFalse( + net.has_url_connectivity(self.url), + 'Expected False on url timeout') + + def test_true_on_url_connectivity_failure(self): + httpretty.register_uri(httpretty.GET, self.url, body={}, status=404) + self.assertFalse( + net.has_url_connectivity(self.url), 'Expected False on url fail') -- cgit v1.2.3