diff --git a/cloudbaseinit/metadata/services/nocloudservice.py b/cloudbaseinit/metadata/services/nocloudservice.py index bcb697cb..edea88e9 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 @@ def parse(self, network_config): 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 @@ def parse(self, network_config): ) +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): @@ -337,11 +602,4 @@ def get_network_details_v2(self): 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 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 @@ def load(self): % 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 @@ def get_admin_username(self): 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 02e7e34b..192dd6cc 100644 --- a/cloudbaseinit/tests/metadata/services/test_nocloudservice.py +++ b/cloudbaseinit/tests/metadata/services/test_nocloudservice.py @@ -28,6 +28,159 @@ 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 @@ def setUp(self): 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 @@ def test_network_details_v2(self): 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 @@ def test_network_details_v2(self): 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): @@ -261,8 +503,6 @@ def test_get_public_keys_alt_fmt(self, mock_get_metadata): ('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 ddt +from cloudbaseinit.utils import serialization + try: import unittest.mock as mock except ImportError: @@ -24,12 +26,83 @@ 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 @@ def test_load_rpc_tool_not_existent(self): @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 @@ def test_load_fail(self): 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 @@ def guest_info_side_effect(*args, **kwargs): 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