From eb38c50c5904c60116fa096981a2eda73a8e1557 Mon Sep 17 00:00:00 2001 From: Zhongcheng Lao Date: Wed, 31 Jan 2024 11:20:06 +0800 Subject: [PATCH] Add network config support to VMware guest info service This commit adds the network config support for VMware environment. Both NetworkConfig V1 and V2 formats are supported to align with cloud-init. Change-Id: I6c686a88be253858e9f46599f10dca571a76b85e Signed-off-by: Zhongcheng Lao --- .../services/vmwareguestinfoservice.py | 188 +++++++++++++++++- cloudbaseinit/plugins/common/networkconfig.py | 8 +- .../services/test_vmwareguestinfoservice.py | 125 +++++++++++- doc/source/services.rst | 15 ++ 4 files changed, 324 insertions(+), 12 deletions(-) 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