diff --git a/cloudbaseinit/metadata/services/vmwareguestinfoservice.py b/cloudbaseinit/metadata/services/vmwareguestinfoservice.py index e469c1af..09ce2a8e 100644 --- a/cloudbaseinit/metadata/services/vmwareguestinfoservice.py +++ b/cloudbaseinit/metadata/services/vmwareguestinfoservice.py @@ -14,15 +14,20 @@ # under the License. import base64 +import collections +import copy import gzip import io import os +import netaddr from oslo_log import log as oslo_logging from cloudbaseinit import conf as cloudbaseinit_conf from cloudbaseinit import exception from cloudbaseinit.metadata.services import base +from cloudbaseinit.metadata.services import nocloudservice +from cloudbaseinit.models import network as network_model from cloudbaseinit.osutils import factory as osutils_factory from cloudbaseinit.utils import serialization @@ -30,6 +35,10 @@ CONF = cloudbaseinit_conf.CONF LOG = oslo_logging.getLogger(__name__) +DEFAULT_GATEWAY_CIDR_IPV4 = u"0.0.0.0/0" +DEFAULT_GATEWAY_CIDR_IPV6 = u"::/0" + + class VMwareGuestInfoService(base.BaseMetadataService): def __init__(self): super(VMwareGuestInfoService, self).__init__() @@ -114,16 +123,16 @@ class VMwareGuestInfoService(base.BaseMetadataService): % self._rpc_tool_path) return False - self._meta_data = serialization.parse_json_yaml( - self._get_guest_data('metadata')) + metadata = self._get_guest_data('metadata') + self._meta_data = serialization.parse_json_yaml(metadata) \ + if metadata else {} if not isinstance(self._meta_data, dict): LOG.warning("Instance metadata is not a dictionary.") self._meta_data = {} self._user_data = self._get_guest_data('userdata') - if self._meta_data or self._user_data: - return True + return True if self._meta_data or self._user_data else None def _get_data(self, path): pass @@ -151,3 +160,174 @@ class VMwareGuestInfoService(base.BaseMetadataService): def get_admin_password(self): return self._meta_data.get('admin-password') + + def get_network_details_v2(self): + """Return a `NetworkDetailsV2` object.""" + network = self._process_network_config(self._meta_data) + if not network: + LOG.info("V2 network metadata not found") + return + + links, networks, services = self._parse_network_data( + network.get("config", [])) + return network_model.NetworkDetailsV2( + links=links, + networks=networks, + services=services + ) + + @staticmethod + def _parse_network_data(networks_data): + if networks_data.get('version') == 1: + return nocloudservice.NoCloudNetworkConfigV1Parser().parse( + networks_data["config"]) + + links = [] + networks = [] + services = [] + + for eth_id, network_data in networks_data.get("ethernets", {}).items(): + eth_name = network_data.get("set-name", eth_id) + mac_address = network_data.get("match", {}).get("macaddress", {}) + link = network_model.Link( + id=eth_id, + name=eth_name, + type=network_model.LINK_TYPE_PHYSICAL, + enabled=True, + mac_address=mac_address, + mtu=None, + bond=None, + vlan_link=None, + vlan_id=None) + links.append(link) + + nameservers = network_data.get("nameservers") + nameserver_addresses = nameservers.get("addresses", []) \ + if nameservers else [] + if nameserver_addresses: + service = network_model.NameServerService( + addresses=nameserver_addresses, + search=None + ) + services.append(service) + + routes = [] + # handle route config in deprecated gateway4/gateway6 + gateway4 = network_data.get("gateway4") + gateway6 = network_data.get("gateway6") + default_route = None + if gateway6 and netaddr.valid_ipv6(gateway6): + default_route = network_model.Route( + network_cidr=DEFAULT_GATEWAY_CIDR_IPV6, + gateway=gateway6) + elif gateway4 and netaddr.valid_ipv4(gateway4): + default_route = network_model.Route( + network_cidr=DEFAULT_GATEWAY_CIDR_IPV4, + gateway=gateway4) + if default_route: + routes.append(default_route) + + # netplan format config + routes_config = network_data.get("routes", {}) + for route_config in routes_config: + network_cidr = route_config.get("to") + gateway = route_config.get("via") + if network_cidr.lower() == "default": + if netaddr.valid_ipv6(gateway): + network_cidr = DEFAULT_GATEWAY_CIDR_IPV6 + else: + network_cidr = DEFAULT_GATEWAY_CIDR_IPV4 + route = network_model.Route( + network_cidr=network_cidr, + gateway=gateway) + routes.append(route) + + addresses = network_data.get("addresses", []) + for addr in addresses: + network = network_model.Network( + link=eth_name, + address_cidr=addr, + dns_nameservers=nameserver_addresses, + routes=routes + ) + networks.append(network) + + return links, networks, services + + def _decode(self, key, enc_type, data): + """Returns the decoded string value of data + + _decode returns the decoded string value of data + key is a string used to identify the data being decoded in log messages + """ + LOG.debug("Getting encoded data for key=%s, enc=%s", key, enc_type) + + if enc_type in ["gzip+base64", "gz+b64"]: + LOG.debug("Decoding %s format %s", enc_type, key) + raw_data = self._decode_data(data, True, True) + elif enc_type in ["base64", "b64"]: + LOG.debug("Decoding %s format %s", enc_type, key) + raw_data = self._b64d(data) + else: + LOG.debug("Plain-text data %s", key) + raw_data = data + + if isinstance(raw_data, str): + return raw_data + + return raw_data.decode('utf-8') + + @staticmethod + def load_json_or_yaml(data): + """Load a JSON or YAML string into a dictionary + + load first attempts to unmarshal the provided data as JSON, and if + that fails then attempts to unmarshal the data as YAML. If data is + None then a new dictionary is returned. + """ + if not data: + return {} + # If data is already a dictionary, here will return it directly. + if isinstance(data, dict): + return data + + return serialization.parse_json_yaml(data) + + @staticmethod + def _b64d(source): + # Base64 decode some data, accepting bytes or unicode/str, and + # returning str/unicode if the result is utf-8 compatible, + # otherwise returning bytes. + decoded = base64.b64decode(source) + try: + return decoded.decode("utf-8") + except UnicodeDecodeError: + return decoded + + def _process_network_config(self, data): + """Loads and parse the optional network configuration.""" + if not data: + return {} + network = None + if "network" in data: + network = data["network"] + + network_enc = None + if "network.encoding" in data: + network_enc = data["network.encoding"] + + if network: + if isinstance(network, collections.abc.Mapping): + LOG.debug("network data copied to 'config' key") + network = {"config": copy.deepcopy(network)} + else: + LOG.debug("network data to be decoded %s", network) + dec_net = self._decode("metadata.network", + network_enc, network) + network = { + "config": self.load_json_or_yaml(dec_net), + } + + LOG.debug("network data %s", network) + + return network diff --git a/cloudbaseinit/plugins/common/networkconfig.py b/cloudbaseinit/plugins/common/networkconfig.py index 8704721c..6ac29e19 100644 --- a/cloudbaseinit/plugins/common/networkconfig.py +++ b/cloudbaseinit/plugins/common/networkconfig.py @@ -143,7 +143,13 @@ class NetworkConfigPlugin(plugin_base.BasePlugin): LOG.warn("Missing details for adapter %s", mac) continue - name = osutils.get_network_adapter_name_by_mac_address(mac) + try: + name = osutils.get_network_adapter_name_by_mac_address(mac) + except exception.ItemNotFoundException: + LOG.warn('Network interface with MAC address "%s" not found', + mac) + continue + LOG.info("Configuring network adapter: %s", name) # In v6 only case, nic.address and nic.netmask could be unset diff --git a/cloudbaseinit/tests/metadata/services/test_vmwareguestinfoservice.py b/cloudbaseinit/tests/metadata/services/test_vmwareguestinfoservice.py index cda62b61..9c70aaca 100644 --- a/cloudbaseinit/tests/metadata/services/test_vmwareguestinfoservice.py +++ b/cloudbaseinit/tests/metadata/services/test_vmwareguestinfoservice.py @@ -17,6 +17,8 @@ import unittest import ddt +from cloudbaseinit.utils import serialization + try: import unittest.mock as mock except ImportError: @@ -24,12 +26,81 @@ except ImportError: from cloudbaseinit import conf as cloudbaseinit_conf from cloudbaseinit import exception +from cloudbaseinit.models import network as network_model from cloudbaseinit.tests import testutils CONF = cloudbaseinit_conf.CONF BASE_MODULE_PATH = 'cloudbaseinit.metadata.services.vmwareguestinfoservice' MODULE_PATH = BASE_MODULE_PATH + '.VMwareGuestInfoService' +NETWORK_CONFIG_TEST_DATA_V1 = """ +version: 1 +config: +- type: physical + name: eth0 + mac_address: "00:50:56:a1:8e:43" + subnets: + - type: static + address: 172.26.0.37 + netmask: 255.255.255.240 + gateway: 172.26.0.33 + dns_nameservers: + - 10.20.145.1 + - 10.20.145.2 +""" +NETWORK_CONFIG_TEST_DATA_V2 = """ +version: 2 +ethernets: + eth0: + match: + macaddress: "00:50:56:a1:8e:43" + set-name: "eth0" + addresses: + - 172.26.0.37/28 + gateway4: 172.26.0.33 + nameservers: + addresses: + - 10.20.145.1 + - 10.20.145.2 +""" +EXPECTED_NETWORK_LINK = network_model.Link( + id="eth0", + name="eth0", + type=network_model.LINK_TYPE_PHYSICAL, + enabled=True, + mac_address="00:50:56:a1:8e:43", + mtu=None, + bond=None, + vlan_link=None, + vlan_id=None) +EXPECTED_NETWORK_NETWORK = network_model.Network( + link="eth0", + address_cidr="172.26.0.37/28", + dns_nameservers=["10.20.145.1", "10.20.145.2"], + routes=[network_model.Route( + network_cidr="0.0.0.0/0", + gateway="172.26.0.33")] +) +EXPECTED_NETWORK_NAME_SERVER = network_model.NameServerService( + addresses=['10.20.145.1', '10.20.145.2'], + search=None) +EXPECTED_NETWORK_DETAILS_V1 = network_model.NetworkDetailsV2( + links=[EXPECTED_NETWORK_LINK], + networks=[EXPECTED_NETWORK_NETWORK], + services=[] +) +EXPECTED_NETWORK_DETAILS_V2 = network_model.NetworkDetailsV2( + links=[EXPECTED_NETWORK_LINK], + networks=[EXPECTED_NETWORK_NETWORK], + services=[EXPECTED_NETWORK_NAME_SERVER] +) +NETWORK_CONFIG_TEST_DATA_V2_GZIPB64 = """ +network: | + H4sIAHWT3mUCA22OSQrDMAxF9zmFyD6uPGRAtzGJaLqIC5ZJ6e3roYVSCgJJ/389dHKU2z0QmI7TzjFwEuo + A8oKlAxw+rXsby7L6bYssQtAj0phrIq9pYXK2rynhNAR/cE4UShPfVyyNNICejTKTQmXni1mqePWJH/7p6M + u01Sk44XjmZz+f/AArEpVBpd2o9B/NdC+Zoo9N7AAAAA== +network.encoding: gzip+base64 +""" class FakeException(Exception): @@ -90,32 +161,43 @@ class VMwareGuestInfoServiceTest(unittest.TestCase): @mock.patch(MODULE_PATH + "._get_guest_data") def _test_load_meta_data(self, mock_get_guestinfo, mock_parse, mock_os_path_exists, parse_return=None, - get_guest_data_result=None, exception=False, + get_guest_data_results=None, exception=False, expected_result=None, meta_data_return=False): mock_os_path_exists.return_value = True mock_parse.return_value = parse_return if not exception: - mock_get_guestinfo.return_value = get_guest_data_result + mock_get_guestinfo.side_effect = get_guest_data_results result = self._service.load() self.assertEqual(result, expected_result) - mock_get_guestinfo.assert_called_with('userdata') - mock_parse.assert_called_once_with(get_guest_data_result) + self.assertEqual(mock_get_guestinfo.call_args_list[0].args, + ('metadata',)) + self.assertEqual(mock_get_guestinfo.call_args_list[1].args, + ('userdata',)) + if get_guest_data_results and len(get_guest_data_results) > 1 \ + and get_guest_data_results[0]: + mock_parse.assert_called_once_with(get_guest_data_results[0]) self.assertEqual(mock_get_guestinfo.call_count, 2) self.assertEqual(self._service._meta_data, meta_data_return) - self.assertEqual(self._service._user_data, get_guest_data_result) + self.assertEqual(self._service._user_data, + get_guest_data_results[1]) else: mock_get_guestinfo.side_effect = FakeException("Fake") self.assertRaises(FakeException, self._service.load) def test_load_no_meta_data(self): - self._test_load_meta_data(meta_data_return={}) + self._test_load_meta_data(meta_data_return={}, + expected_result=True, + get_guest_data_results=[None, + "fake userdata"]) def test_load_no_user_data(self): parse_return = {"fake": "metadata"} self._test_load_meta_data(parse_return=parse_return, expected_result=True, + get_guest_data_results=["fake metadata", + None], meta_data_return=parse_return) def test_load_fail(self): @@ -125,12 +207,15 @@ class VMwareGuestInfoServiceTest(unittest.TestCase): def test_load(self): parse_return = {"fake": "metadata"} self._test_load_meta_data(parse_return=parse_return, - get_guest_data_result="fake userdata", + get_guest_data_results=["fake metadata", + "fake userdata"], expected_result=True, meta_data_return=parse_return) def test_load_no_dict_metadata(self): self._test_load_meta_data(parse_return="not_a_dict", + get_guest_data_results=["fake metadata", + None], expected_result=None, meta_data_return={}) @ddt.data((None, []), @@ -181,6 +266,32 @@ class VMwareGuestInfoServiceTest(unittest.TestCase): mock_decode_data.assert_called_once_with(data_key_ret, is_base64, is_gzip) + @ddt.data(({}, None), + (serialization.parse_json_yaml(NETWORK_CONFIG_TEST_DATA_V1), + EXPECTED_NETWORK_DETAILS_V1), + (serialization.parse_json_yaml(NETWORK_CONFIG_TEST_DATA_V2), + EXPECTED_NETWORK_DETAILS_V2)) + @ddt.unpack + def test_get_network_details(self, network_data, expected_return_value): + self._service._meta_data = { + "network": network_data + } + + network_v2 = self._service.get_network_details_v2() + self.assertEqual(network_v2, expected_return_value) + + @mock.patch(MODULE_PATH + "._get_guest_data") + @mock.patch('os.path.exists') + def test_get_network_details_v2_b64(self, mock_os_path_exists, + mock_get_guest_data): + mock_os_path_exists.return_value = True + mock_get_guest_data.return_value = NETWORK_CONFIG_TEST_DATA_V2_GZIPB64 + + self._service.load() + network_v2 = self._service.get_network_details_v2() + + self.assertEqual(network_v2, EXPECTED_NETWORK_DETAILS_V2) + @mock.patch(MODULE_PATH + "._get_guestinfo_value") def test_get_guest_data_fail(self, mock_get_guestinfo_value): diff --git a/doc/source/services.rst b/doc/source/services.rst index a38b6438..2217ae42 100644 --- a/doc/source/services.rst +++ b/doc/source/services.rst @@ -518,6 +518,20 @@ Example metadata in yaml format: public-keys-data: | ssh-key 1 ssh-key 2 + network: + version: 2 + ethernets: + id0: + match: + macaddress: "00:50:56:a1:8e:43" + set-name: "eth0" + addresses: + - 172.26.0.37/28 + gateway4: 172.26.0.33 + nameservers: + addresses: + - 10.20.145.1 + - 10.20.145.2 This metadata content needs to be set as string in the guestinfo dictionary, thus needs to be converted to base64 (it is recommended to @@ -548,6 +562,7 @@ Capabilities: * instance id * hostname * public keys + * static network configuration * admin user name * admin user password * user data