From f0fe66b30bf95d05ebecb74d8059ea2cdda37d0f 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. NetworkConfig V2 format specification can be found at https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html Change-Id: I6c686a88be253858e9f46599f10dca571a76b85e Signed-off-by: Zhongcheng Lao --- .../metadata/services/nocloudservice.py | 274 +++++++++++++- .../services/vmwareguestinfoservice.py | 96 ++++- .../metadata/services/test_nocloudservice.py | 358 +++++++++++++++--- .../services/test_vmwareguestinfoservice.py | 124 +++++- doc/source/services.rst | 15 + 5 files changed, 789 insertions(+), 78 deletions(-) diff --git a/cloudbaseinit/metadata/services/nocloudservice.py b/cloudbaseinit/metadata/services/nocloudservice.py index 591dd03c..9dc8cf2a 100644 --- a/cloudbaseinit/metadata/services/nocloudservice.py +++ b/cloudbaseinit/metadata/services/nocloudservice.py @@ -11,6 +11,8 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. + +import copy import netaddr from oslo_log import log as oslo_logging @@ -235,6 +237,10 @@ class NoCloudNetworkConfigV1Parser(object): networks = [] services = [] + network_config = network_config.get('network') \ + if network_config else {} + network_config = network_config.get('config') \ + if network_config else None if not network_config: LOG.warning("Network configuration is empty") return @@ -272,6 +278,265 @@ class NoCloudNetworkConfigV1Parser(object): ) +class NoCloudNetworkConfigV2Parser(object): + DEFAULT_GATEWAY_CIDR_IPV4 = u"0.0.0.0/0" + DEFAULT_GATEWAY_CIDR_IPV6 = u"::/0" + + NETWORK_LINK_TYPE_ETHERNET = 'ethernet' + NETWORK_LINK_TYPE_BOND = 'bond' + NETWORK_LINK_TYPE_VLAN = 'vlan' + NETWORK_LINK_TYPE_BRIDGE = 'bridge' + + SUPPORTED_NETWORK_CONFIG_TYPES = { + NETWORK_LINK_TYPE_ETHERNET: 'ethernets', + NETWORK_LINK_TYPE_BOND: 'bonds', + NETWORK_LINK_TYPE_VLAN: 'vlans', + } + + def _parse_mac_address(self, item): + return item.get("match", {}).get("macaddress") + + def _parse_addresses(self, item, link_name): + networks = [] + services = [] + + routes = [] + # handle route config in deprecated gateway4/gateway6 + gateway4 = item.get("gateway4") + gateway6 = item.get("gateway6") + default_route = None + if gateway6 and netaddr.valid_ipv6(gateway6): + default_route = network_model.Route( + network_cidr=self.DEFAULT_GATEWAY_CIDR_IPV6, + gateway=gateway6) + elif gateway4 and netaddr.valid_ipv4(gateway4): + default_route = network_model.Route( + network_cidr=self.DEFAULT_GATEWAY_CIDR_IPV4, + gateway=gateway4) + if default_route: + routes.append(default_route) + + # netplan format config + routes_config = item.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 = self.DEFAULT_GATEWAY_CIDR_IPV6 + else: + network_cidr = self.DEFAULT_GATEWAY_CIDR_IPV4 + route = network_model.Route( + network_cidr=network_cidr, + gateway=gateway) + routes.append(route) + + nameservers = item.get("nameservers") + nameserver_addresses = nameservers.get("addresses", []) \ + if nameservers else [] + searches = nameservers.get("search", []) + service = network_model.NameServerService( + addresses=nameserver_addresses, + search=','.join(searches) if searches else None, + ) + services.append(service) + + addresses = item.get("addresses", []) + for addr in addresses: + network = network_model.Network( + link=link_name, + address_cidr=addr, + dns_nameservers=nameserver_addresses, + routes=routes + ) + networks.append(network) + + return networks, services + + def _parse_ethernet_config_item(self, item): + if not item.get('name'): + LOG.warning("Ethernet does not have a name.") + return + + name = item.get('name') + eth_name = item.get("set-name", name) + link = network_model.Link( + id=name, + name=eth_name, + type=network_model.LINK_TYPE_PHYSICAL, + enabled=True, + mac_address=self._parse_mac_address(item), + mtu=item.get('mtu'), + bond=None, + vlan_link=None, + vlan_id=None + ) + + networks, services = self._parse_addresses(item, link.name) + return network_model.NetworkDetailsV2( + links=[link], + networks=networks, + services=services, + ) + + def _parse_bond_config_item(self, item): + if not item.get('name'): + LOG.warning("Bond does not have a name.") + return + + bond_params = item.get('parameters') + if not bond_params: + LOG.warning("Bond does not have parameters") + return + + bond_mode = bond_params.get('mode') + if bond_mode not in network_model.AVAILABLE_BOND_TYPES: + raise exception.CloudbaseInitException( + "Unsupported bond mode: %s" % bond_mode) + + bond_lacp_rate = None + if bond_mode == network_model.BOND_TYPE_8023AD: + bond_lacp_rate = bond_params.get('lacp-rate') + if (bond_lacp_rate and bond_lacp_rate not in + network_model.AVAILABLE_BOND_LACP_RATES): + raise exception.CloudbaseInitException( + "Unsupported bond lacp rate: %s" % bond_lacp_rate) + + bond_xmit_hash_policy = bond_params.get('transmit-hash-policy') + if (bond_xmit_hash_policy and bond_xmit_hash_policy not in + network_model.AVAILABLE_BOND_LB_ALGORITHMS): + raise exception.CloudbaseInitException( + "Unsupported bond hash policy: %s" % + bond_xmit_hash_policy) + + bond_interfaces = item.get('interfaces') + + bond = network_model.Bond( + members=bond_interfaces, + type=bond_mode, + lb_algorithm=bond_xmit_hash_policy, + lacp_rate=bond_lacp_rate, + ) + + link = network_model.Link( + id=item.get('name'), + name=item.get('name'), + type=network_model.LINK_TYPE_BOND, + enabled=True, + mac_address=self._parse_mac_address(item), + mtu=item.get('mtu'), + bond=bond, + vlan_link=None, + vlan_id=None + ) + + networks, services = self._parse_addresses(item, link.name) + return network_model.NetworkDetailsV2( + links=[link], + networks=networks, + services=services + ) + + def _parse_vlan_config_item(self, item): + if not item.get('name'): + LOG.warning("VLAN NIC does not have a name.") + return + + link = network_model.Link( + id=item.get('name'), + name=item.get('name'), + type=network_model.LINK_TYPE_VLAN, + enabled=True, + mac_address=self._parse_mac_address(item), + mtu=item.get('mtu'), + bond=None, + vlan_link=item.get('link'), + vlan_id=item.get('id') + ) + + networks, services = self._parse_addresses(item, link.name) + return network_model.NetworkDetailsV2( + links=[link], + networks=networks, + services=services, + ) + + def _get_network_config_parser(self, parser_type): + parsers = { + self.NETWORK_LINK_TYPE_ETHERNET: self._parse_ethernet_config_item, + self.NETWORK_LINK_TYPE_BOND: self._parse_bond_config_item, + self.NETWORK_LINK_TYPE_VLAN: self._parse_vlan_config_item, + } + parser = parsers.get(parser_type) + if not parser: + raise exception.CloudbaseInitException( + "Network config parser '%s' does not exist", + parser_type) + return parser + + def parse(self, network_config): + links = [] + networks = [] + services = [] + + network_config = network_config.get('network') \ + if network_config else {} + if not network_config: + LOG.warning("Network configuration is empty") + return + + if not isinstance(network_config, dict): + LOG.warning("Network config '%s' is not a dict.", + network_config) + return + + for singular, plural in self.SUPPORTED_NETWORK_CONFIG_TYPES.items(): + network_config_items = network_config.get(plural, {}) + if not network_config_items: + continue + + if not isinstance(network_config_items, dict): + LOG.warning("Network config '%s' is not a dict", + network_config_items) + continue + + for name, network_config_item in network_config_items.items(): + if not isinstance(network_config_item, dict): + LOG.warning( + "network config item '%s' of type %s is not a dict", + network_config_item, singular) + continue + + item = copy.deepcopy(network_config_item) + item['name'] = name + net_details = ( + self._get_network_config_parser(singular) + (item)) + + if net_details: + links += net_details.links + networks += net_details.networks + services += net_details.services + + return network_model.NetworkDetailsV2( + links=links, + networks=networks, + services=services + ) + + +class NoCloudNetworkConfigParser(object): + + @staticmethod + def parse(network_data): + network_data_version = network_data.get("network", {}).get("version") + if network_data_version == 1: + network_config_parser = NoCloudNetworkConfigV1Parser() + return network_config_parser.parse(network_data) + + return NoCloudNetworkConfigV2Parser().parse(network_data) + + class NoCloudConfigDriveService(baseconfigdrive.BaseConfigDriveService): def __init__(self): @@ -334,11 +599,4 @@ class NoCloudConfigDriveService(baseconfigdrive.BaseConfigDriveService): LOG.exception("V2 network metadata could not be deserialized") return - network_data_version = network_data.get("version") - if network_data_version != 1: - LOG.error("Network data version '%s' is not supported", - network_data_version) - return - - network_config_parser = NoCloudNetworkConfigV1Parser() - return network_config_parser.parse(network_data.get("config")) + return NoCloudNetworkConfigParser.parse(network_data) diff --git a/cloudbaseinit/metadata/services/vmwareguestinfoservice.py b/cloudbaseinit/metadata/services/vmwareguestinfoservice.py index e469c1af..2f865c72 100644 --- a/cloudbaseinit/metadata/services/vmwareguestinfoservice.py +++ b/cloudbaseinit/metadata/services/vmwareguestinfoservice.py @@ -14,6 +14,8 @@ # under the License. import base64 +import collections +import copy import gzip import io import os @@ -23,6 +25,7 @@ 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.osutils import factory as osutils_factory from cloudbaseinit.utils import serialization @@ -114,16 +117,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 +154,88 @@ 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 + + return nocloudservice.NoCloudNetworkConfigParser.parse(network) + + 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 not network: + return {} + + if isinstance(network, collections.abc.Mapping): + network = copy.deepcopy(network) + else: + LOG.debug("network data to be decoded %s", network) + dec_net = self._decode("metadata.network", network_enc, network) + network = self._load_json_or_yaml(dec_net) + + LOG.debug("network data %s", network) + return {"network": network} diff --git a/cloudbaseinit/tests/metadata/services/test_nocloudservice.py b/cloudbaseinit/tests/metadata/services/test_nocloudservice.py index e94e71c3..b364eec5 100644 --- a/cloudbaseinit/tests/metadata/services/test_nocloudservice.py +++ b/cloudbaseinit/tests/metadata/services/test_nocloudservice.py @@ -28,6 +28,159 @@ from cloudbaseinit.tests import testutils from cloudbaseinit.utils import serialization MODULE_PATH = "cloudbaseinit.metadata.services.nocloudservice" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_EMPTY_CONFIG = """ +network: + version: 1 + t: 1 +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_CONFIG_IS_NOT_LIST = """ +network: + version: 1 + config: { + test: abc + } +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_CONFIG_ITEM_IS_NOT_DICT = """ +network: + version: 1 + config: + - ['test', 'abc'] +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_ROUTER_CONFIG_NOT_SUPPORTED = """ +network: + version: 1 + config: + - type: router +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1 = """ +network: + version: 1 + config: + - type: physical + name: interface0 + mac_address: "52:54:00:12:34:00" + mtu: 1450 + subnets: + - type: static + address: 192.168.1.10 + netmask: 255.255.255.0 + gateway: 192.168.1.1 + dns_nameservers: + - 192.168.1.11 + - type: bond + name: bond0 + bond_interfaces: + - gbe0 + - gbe1 + mac_address: "52:54:00:12:34:00" + params: + bond-mode: active-backup + bond-lacp-rate: false + mtu: 1450 + subnets: + - type: static + address: 192.168.1.10 + netmask: 255.255.255.0 + dns_nameservers: + - 192.168.1.11 + - type: vlan + name: vlan0 + vlan_link: eth1 + vlan_id: 150 + mac_address: "52:54:00:12:34:00" + mtu: 1450 + subnets: + - type: static + address: 192.168.1.10 + netmask: 255.255.255.0 + dns_nameservers: + - 192.168.1.11 + - type: nameserver + address: + - 192.168.23.2 + - 8.8.8.8 + search: acme.local +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_EMPTY_CONFIG = """ +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_CONFIG_IS_NOT_DICT = """ +network: +- config +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_CONFIG_ITEM_IS_NOT_DICT = """ +network: + version: 2 + ethernets: + - test +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_CONFIG_ITEM_SETTING_IS_NOT_DICT = """ +network: + version: 2 + ethernets: + eth0: + - test +""" +NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2 = """ +network: + version: 2 + ethernets: + interface0: + match: + macaddress: "52:54:00:12:34:00" + set-name: "eth0" + addresses: + - 192.168.1.10/24 + gateway4: 192.168.1.1 + nameservers: + addresses: + - 192.168.1.11 + - 192.168.1.12 + search: + - acme.local + mtu: 1450 + interface1: + set-name: "interface1" + addresses: + - 192.168.1.100/24 + gateway4: 192.168.1.1 + nameservers: + addresses: + - 192.168.1.11 + - 192.168.1.12 + search: + - acme.local + bonds: + bond0: + interfaces: ["gbe0", "gbe1"] + match: + macaddress: "52:54:00:12:34:00" + parameters: + mode: active-backup + lacp-rate: false + addresses: + - 192.168.1.10/24 + nameservers: + addresses: + - 192.168.1.11 + mtu: 1450 + vlans: + vlan0: + id: 150 + link: eth1 + dhcp4: yes + match: + macaddress: "52:54:00:12:34:00" + addresses: + - 192.168.1.10/24 + nameservers: + addresses: + - 192.168.1.11 + mtu: 1450 + bridges: + br0: + interfaces: ['eth0'] + dhcp4: true +""" @ddt.ddt @@ -37,15 +190,18 @@ class TestNoCloudNetworkConfigV1Parser(unittest.TestCase): self._parser = module.NoCloudNetworkConfigV1Parser() self.snatcher = testutils.LogSnatcher(MODULE_PATH) - @ddt.data(('', ('Network configuration is empty', None)), - ('{t: 1}', - ("Network config '{'t': 1}' is not a list", None)), - ('["1"]', - ("Network config item '1' is not a dictionary", - nm.NetworkDetailsV2(links=[], networks=[], services=[]))), - ('[{"type": "router"}]', - ("Network config type 'router' is not supported", - nm.NetworkDetailsV2(links=[], networks=[], services=[])))) + @ddt.data( + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_EMPTY_CONFIG, + ('Network configuration is empty', None)), + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_CONFIG_IS_NOT_LIST, + ("is not a list", None)), + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_CONFIG_ITEM_IS_NOT_DICT, + ("is not a dictionary", + nm.NetworkDetailsV2(links=[], networks=[], services=[]))), + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1_ROUTER_CONFIG_NOT_SUPPORTED, + ("Network config type 'router' is not supported", + nm.NetworkDetailsV2(links=[], networks=[], services=[]))) + ) @ddt.unpack def test_parse_empty_result(self, input, expected_result): @@ -122,55 +278,8 @@ class TestNoCloudNetworkConfigV1Parser(unittest.TestCase): addresses=['192.168.23.2', '8.8.8.8'], search='acme.local') - parser_data = """ - - type: physical - name: interface0 - mac_address: "52:54:00:12:34:00" - mtu: 1450 - subnets: - - type: static - address: 192.168.1.10 - netmask: 255.255.255.0 - gateway: 192.168.1.1 - dns_nameservers: - - 192.168.1.11 - - type: bond - name: bond0 - bond_interfaces: - - gbe0 - - gbe1 - mac_address: "52:54:00:12:34:00" - params: - bond-mode: active-backup - bond-lacp-rate: false - mtu: 1450 - subnets: - - type: static - address: 192.168.1.10 - netmask: 255.255.255.0 - dns_nameservers: - - 192.168.1.11 - - type: vlan - name: vlan0 - vlan_link: eth1 - vlan_id: 150 - mac_address: "52:54:00:12:34:00" - mtu: 1450 - subnets: - - type: static - address: 192.168.1.10 - netmask: 255.255.255.0 - dns_nameservers: - - 192.168.1.11 - - type: nameserver - address: - - 192.168.23.2 - - 8.8.8.8 - search: acme.local - """ - result = self._parser.parse( - serialization.parse_json_yaml(parser_data)) + serialization.parse_json_yaml(NOCLOUD_NETWORK_CONFIG_TEST_DATA_V1)) self.assertEqual(result.links[0], expected_link) self.assertEqual(result.networks[0], expected_network) @@ -184,6 +293,139 @@ class TestNoCloudNetworkConfigV1Parser(unittest.TestCase): self.assertEqual(result.services[0], expected_nameservers) +@ddt.ddt +class TestNoCloudNetworkConfigV2Parser(unittest.TestCase): + def setUp(self): + module = importlib.import_module(MODULE_PATH) + self._parser = module.NoCloudNetworkConfigV2Parser() + self.snatcher = testutils.LogSnatcher(MODULE_PATH) + + @ddt.data( + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_EMPTY_CONFIG, + ('Network configuration is empty', None)), + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_CONFIG_IS_NOT_DICT, + ('is not a dict', None)), + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_CONFIG_ITEM_IS_NOT_DICT, + ('is not a dict', + nm.NetworkDetailsV2(links=[], networks=[], services=[])), + ), + (NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2_CONFIG_ITEM_SETTING_IS_NOT_DICT, + ('of type ethernet is not a dict', + nm.NetworkDetailsV2(links=[], networks=[], services=[])), + ) + ) + @ddt.unpack + def test_parse_empty_result(self, input, expected_result): + with self.snatcher: + result = self._parser.parse(serialization.parse_json_yaml(input)) + + self.assertEqual(True, expected_result[0] in self.snatcher.output[0]) + self.assertEqual(result, expected_result[1]) + + def test_network_details_v2(self): + expected_bond = nm.Bond( + members=["gbe0", "gbe1"], + type=nm.BOND_TYPE_ACTIVE_BACKUP, + lb_algorithm=None, + lacp_rate=None, + ) + expected_link_bond = nm.Link( + id='bond0', + name='bond0', + type=nm.LINK_TYPE_BOND, + enabled=True, + mac_address="52:54:00:12:34:00", + mtu=1450, + bond=expected_bond, + vlan_link=None, + vlan_id=None, + ) + expected_link = nm.Link( + id='interface0', + name='eth0', + type=nm.LINK_TYPE_PHYSICAL, + enabled=True, + mac_address="52:54:00:12:34:00", + mtu=1450, + bond=None, + vlan_link=None, + vlan_id=None, + ) + expected_link_if1 = nm.Link( + id='interface1', + name='interface1', + type=nm.LINK_TYPE_PHYSICAL, + enabled=True, + mac_address=None, + mtu=None, + bond=None, + vlan_link=None, + vlan_id=None, + ) + expected_link_vlan = nm.Link( + id='vlan0', + name='vlan0', + type=nm.LINK_TYPE_VLAN, + enabled=True, + mac_address="52:54:00:12:34:00", + mtu=1450, + bond=None, + vlan_link='eth1', + vlan_id=150, + ) + expected_network = nm.Network( + link='eth0', + address_cidr='192.168.1.10/24', + dns_nameservers=['192.168.1.11', '192.168.1.12'], + routes=[ + nm.Route(network_cidr='0.0.0.0/0', + gateway="192.168.1.1") + ] + ) + expected_network_if1 = nm.Network( + link='interface1', + address_cidr='192.168.1.100/24', + dns_nameservers=['192.168.1.11', '192.168.1.12'], + routes=[ + nm.Route(network_cidr='0.0.0.0/0', + gateway="192.168.1.1") + ] + ) + + expected_network_bond = nm.Network( + link='bond0', + address_cidr='192.168.1.10/24', + dns_nameservers=['192.168.1.11'], + routes=[], + ) + + expected_network_vlan = nm.Network( + link='vlan0', + address_cidr='192.168.1.10/24', + dns_nameservers=['192.168.1.11'], + routes=[], + ) + expected_nameservers = nm.NameServerService( + addresses=['192.168.1.11', '192.168.1.12'], + search='acme.local') + + result = self._parser.parse( + serialization.parse_json_yaml(NOCLOUD_NETWORK_CONFIG_TEST_DATA_V2)) + + self.assertEqual(result.links[0], expected_link) + self.assertEqual(result.links[1], expected_link_if1) + self.assertEqual(result.networks[0], expected_network) + self.assertEqual(result.networks[1], expected_network_if1) + + self.assertEqual(result.links[2], expected_link_bond) + self.assertEqual(result.networks[2], expected_network_bond) + + self.assertEqual(result.links[3], expected_link_vlan) + self.assertEqual(result.networks[3], expected_network_vlan) + + self.assertEqual(result.services[0], expected_nameservers) + + @ddt.ddt class TestNoCloudConfigDriveService(unittest.TestCase): @@ -251,8 +493,6 @@ class TestNoCloudConfigDriveService(unittest.TestCase): ('1', ('V2 network metadata is not a dictionary', None)), ('{}', ('V2 network metadata is empty', None)), ('{}}', ('V2 network metadata could not be deserialized', None)), - ('{version: 2}', ("Network data version '2' is not supported", - None)), (base.NotExistingMetadataException('exc'), ('V2 network metadata not found', True))) @ddt.unpack diff --git a/cloudbaseinit/tests/metadata/services/test_vmwareguestinfoservice.py b/cloudbaseinit/tests/metadata/services/test_vmwareguestinfoservice.py index cda62b61..6929fb3f 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,83 @@ 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 = """ +network: + 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 = """ +network: + 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 +163,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 +209,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 +268,29 @@ 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_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 d5f085ba..1e6de56e 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