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 <zhongchengl@vmware.com>
This commit is contained in:
Zhongcheng Lao 2024-01-31 11:20:06 +08:00
parent 036aa1641b
commit eb38c50c59
4 changed files with 324 additions and 12 deletions

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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