Add `get_node_network_data` to Neutron NetworkInterface

Implements `get_node_network_data` network interface method for
Neutron networks providing Nova network metadata
(AKA network_data.json) collected from Neutron VIF of ironic
port objects associated with the node.

Co-Authored: Iury Gregory Melo Ferreira <iurygregory@gmail.com>
Change-Id: I0fa742110649ed2786f360e1ef43012e77671620
Story: 2006691
Task: 37071
This commit is contained in:
Ilya Etingof 2019-10-16 12:09:36 +02:00 committed by Iury Gregory Melo Ferreira
parent e461e36ee9
commit c84c6af087
12 changed files with 593 additions and 4 deletions

View File

@ -11,6 +11,7 @@
# under the License.
import copy
import ipaddress
from keystoneauth1 import loading as ks_loading
from neutronclient.common import exceptions as neutron_exceptions
@ -474,6 +475,160 @@ def remove_neutron_ports(task, params):
{'node_uuid': node_uuid})
def _uncidr(cidr, ipv6=False):
"""Convert CIDR network representation into network/netmask form
:param cidr: network in CIDR form
:param ipv6: if `True`, consider `cidr` being IPv6
:returns: a tuple of network/host number in dotted
decimal notation, netmask in dotted decimal notation
"""
net = ipaddress.ip_interface(cidr).network
return str(net.network_address), str(net.netmask)
def get_neutron_port_data(port_id, vif_id, client=None, context=None):
"""Gather Neutron port and network configuration
Query Neutron for port and network configuration, return whatever
is available.
:param port_id: ironic port/portgroup ID.
:param vif_id: Neutron port ID.
:param client: Optional a Neutron client object.
:param context: request context
:type context: ironic.common.context.RequestContext
:raises: NetworkError
:returns: a dict holding network configuration information
associated with this ironic or Neutron port.
"""
if not client:
client = get_client(context=context)
try:
port_config = client.show_port(
vif_id, fields=['id', 'name', 'dns_assignment', 'fixed_ips',
'mac_address', 'network_id'])
except neutron_exceptions.NeutronClientException as e:
msg = (_('Unable to get port info for %(port_id)s. Error: '
'%(err)s') % {'port_id': vif_id, 'err': e})
LOG.exception(msg)
raise exception.NetworkError(msg)
LOG.debug('Received port %(port)s data: %(info)s',
{'port': vif_id, 'info': port_config})
port_config = port_config['port']
port_id = port_config['name'] or port_id
network_id = port_config.get('network_id')
try:
network_config = client.show_network(
network_id, fields=['id', 'mtu', 'subnets'])
except neutron_exceptions.NeutronClientException as e:
msg = (_('Unable to get network info for %(network_id)s. Error: '
'%(err)s') % {'network_id': network_id, 'err': e})
LOG.exception(msg)
raise exception.NetworkError(msg)
LOG.debug('Received network %(network)s data: %(info)s',
{'network': network_id, 'info': network_config})
network_config = network_config['network']
subnets_config = {}
network_data = {
'links': [
{
'id': port_id,
'type': 'vif',
'ethernet_mac_address': port_config['mac_address'],
'vif_id': port_config['id'],
'mtu': network_config['mtu']
}
],
'networks': [
]
}
for fixed_ip in port_config.get('fixed_ips', []):
subnet_id = fixed_ip['subnet_id']
try:
subnet_config = client.show_subnet(
subnet_id, fields=['id', 'name', 'enable_dhcp',
'dns_nameservers', 'host_routes',
'ip_version', 'gateway_ip', 'cidr'])
LOG.debug('Received subnet %(subnet)s data: %(info)s',
{'subnet': subnet_id, 'info': subnet_config})
subnets_config[subnet_id] = subnet_config['subnet']
except neutron_exceptions.NeutronClientException as e:
msg = (_('Unable to get subnet info for %(subnet_id)s. Error: '
'%(err)s') % {'subnet_id': subnet_id, 'err': e})
LOG.exception(msg)
raise exception.NetworkError(msg)
subnet_config = subnets_config[subnet_id]
subnet_network, netmask = _uncidr(
subnet_config['cidr'], subnet_config['ip_version'] == 6)
network = {
'id': fixed_ip['subnet_id'],
'network_id': port_config['network_id'],
'type': 'ipv%s' % subnet_config['ip_version'],
'link': port_id,
'ip_address': fixed_ip['ip_address'],
'netmask': netmask,
'routes': [
]
}
# TODO(etingof): Adding default route if gateway is present.
# This is a hack, Neutron should have given us a route.
if subnet_config['gateway_ip']:
zero_addr = ('::0' if subnet_config['ip_version'] == 6
else '0.0.0.0')
route = {
'network': zero_addr,
'netmask': zero_addr,
'gateway': subnet_config['gateway_ip']
}
network['routes'].append(route)
for host_config in subnet_config['host_routes']:
subnet_network, netmask = _uncidr(
host_config['destination'],
subnet_config['ip_version'] == 6)
route = {
'network': subnet_network,
'netmask': netmask,
'gateway': host_config['nexthop']
}
network['routes'].append(route)
network_data['networks'].append(network)
return network_data
def get_node_portmap(task):
"""Extract the switch port information for the node.

View File

@ -410,7 +410,7 @@ class VIFPortIDMixin(object):
or self._get_vif_id_by_port_like_obj(p_obj) or None)
def get_node_network_data(self, task):
"""Return network configuration for node NICs.
"""Get network configuration data for node's ports/portgroups.
Gather L2 and L3 network settings from ironic node `network_data`
field. Ironic would eventually pass network configuration to the node
@ -633,3 +633,51 @@ class NeutronVIFPortIDMixin(VIFPortIDMixin):
# DELETING state.
if task.node.provision_state in [states.ACTIVE, states.DELETING]:
neutron.unbind_neutron_port(vif_id, context=task.context)
def get_node_network_data(self, task):
"""Get network configuration data for node ports.
Pull network data from ironic node object if present, otherwise
collect it for Neutron VIFs.
:param task: A TaskManager instance.
:raises: InvalidParameterValue, if the network interface configuration
is invalid.
:raises: MissingParameterValue, if some parameters are missing.
:returns: a dict holding network configuration information adhearing
Nova network metadata layout (`network_data.json`).
"""
# NOTE(etingof): static network data takes precedence
network_data = (
super(NeutronVIFPortIDMixin, self).get_node_network_data(task))
if network_data:
return network_data
node = task.node
LOG.debug('Gathering network data from ports of node '
'%(node)s', {'node': node.uuid})
network_data = collections.defaultdict(list)
for port_obj in task.ports:
vif_port_id = self.get_current_vif(task, port_obj)
LOG.debug('Considering node %(node)s port %(port)s, VIF %(vif)s',
{'node': node.uuid, 'port': port_obj.uuid,
'vif': vif_port_id})
if not vif_port_id:
continue
port_network_data = neutron.get_neutron_port_data(
port_obj.uuid, vif_port_id, context=task.context)
for field, field_data in port_network_data.items():
if field_data:
network_data[field].extend(field_data)
LOG.debug('Collected network data for node %(node)s: %(data)s',
{'node': node.uuid, 'data': network_data})
return network_data

View File

@ -0,0 +1,33 @@
{
"network": {
"admin_state_up": true,
"availability_zone_hints": [],
"availability_zones": [
"nova"
],
"created_at": "2016-03-08T20:19:41",
"dns_domain": "my-domain.org.",
"id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
"ipv4_address_scope": null,
"ipv6_address_scope": null,
"l2_adjacency": false,
"mtu": 1500,
"name": "private-network",
"port_security_enabled": true,
"project_id": "4fd44f30292945e481c7b8a0c8908869",
"qos_policy_id": "6a8454ade84346f59e8d40665f878b2e",
"revision_number": 1,
"router:external": false,
"shared": true,
"status": "ACTIVE",
"subnets": [
"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
],
"tags": ["tag1,tag2"],
"tenant_id": "4fd44f30292945e481c7b8a0c8908869",
"updated_at": "2016-03-08T20:19:41",
"vlan_transparent": false,
"description": "",
"is_default": true
}
}

View File

@ -0,0 +1,33 @@
{
"network": {
"admin_state_up": true,
"availability_zone_hints": [],
"availability_zones": [
"nova"
],
"created_at": "2016-03-08T20:19:41",
"dns_domain": "my-domain.org.",
"id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
"ipv4_address_scope": null,
"ipv6_address_scope": null,
"l2_adjacency": false,
"mtu": 1500,
"name": "private-network",
"port_security_enabled": true,
"project_id": "5199666e520f4aed823710aec37cfd38",
"qos_policy_id": "6a8454ade84346f59e8d40665f878b2e",
"revision_number": 1,
"router:external": false,
"shared": true,
"status": "ACTIVE",
"subnets": [
"54d6f61d-db07-451c-9ab3-b9609b6b6f0b"
],
"tags": ["tag1,tag2"],
"tenant_id": "5199666e520f4aed823710aec37cfd38",
"updated_at": "2016-03-08T20:19:41",
"vlan_transparent": false,
"description": "",
"is_default": true
}
}

View File

@ -0,0 +1,59 @@
{
"port": {
"admin_state_up": true,
"allowed_address_pairs": [],
"binding:host_id": "devstack",
"binding:profile": {},
"binding:vif_details": {
"ovs_hybrid_plug": true,
"port_filter": true
},
"binding:vif_type": "ovs",
"binding:vnic_type": "normal",
"created_at": "2016-03-08T20:19:41",
"data_plane_status": "ACTIVE",
"description": "",
"device_id": "5e3898d7-11be-483e-9732-b2f5eccd2b2e",
"device_owner": "network:router_interface",
"dns_assignment": {
"hostname": "myport",
"ip_address": "10.0.0.2",
"fqdn": "myport.my-domain.org"
},
"dns_domain": "my-domain.org.",
"dns_name": "myport",
"extra_dhcp_opts": [
{
"opt_value": "pxelinux.0",
"ip_version": 4,
"opt_name": "bootfile-name"
}
],
"fixed_ips": [
{
"ip_address": "10.0.0.2",
"subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2"
}
],
"id": "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2",
"ip_allocation": "immediate",
"mac_address": "fa:16:3e:23:fd:d7",
"mac_learning_enabled": false,
"name": "",
"network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
"port_security_enabled": false,
"project_id": "7e02058126cc4950b75f9970368ba177",
"revision_number": 1,
"security_groups": [],
"status": "ACTIVE",
"tags": ["tag1,tag2"],
"tenant_id": "7e02058126cc4950b75f9970368ba177",
"updated_at": "2016-03-08T20:19:41",
"qos_policy_id": "29d5e02e-d5ab-4929-bee4-4a9fc12e22ae",
"resource_request": {
"required": ["CUSTOM_PHYSNET_PUBLIC", "CUSTOM_VNIC_TYPE_NORMAL"],
"resources": {"NET_BW_EGR_KILOBIT_PER_SEC": 1000}
},
"uplink_status_propagation": false
}
}

View File

@ -0,0 +1,59 @@
{
"port": {
"admin_state_up": true,
"allowed_address_pairs": [],
"binding:host_id": "devstack",
"binding:profile": {},
"binding:vif_details": {
"ovs_hybrid_plug": true,
"port_filter": true
},
"binding:vif_type": "ovs",
"binding:vnic_type": "normal",
"created_at": "2016-03-08T20:19:41",
"data_plane_status": "ACTIVE",
"description": "",
"device_id": "5e3898d7-11be-483e-9732-b2f5eccd2b2e",
"device_owner": "network:router_interface",
"dns_assignment": {
"hostname": "myport",
"ip_address": "fd00:203:0:113::2",
"fqdn": "myport.my-domain.org"
},
"dns_domain": "my-domain.org.",
"dns_name": "myport",
"extra_dhcp_opts": [
{
"opt_value": "pxelinux.0",
"ip_version": 6,
"opt_name": "bootfile-name"
}
],
"fixed_ips": [
{
"ip_address": "fd00:203:0:113::2",
"subnet_id": "906e685a-b964-4d58-9939-9cf3af197c67"
}
],
"id": "96d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb8",
"ip_allocation": "immediate",
"mac_address": "52:54:00:4f:ef:b7",
"mac_learning_enabled": false,
"name": "",
"network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
"port_security_enabled": false,
"project_id": "7e02058126cc4950b75f9970368ba177",
"revision_number": 1,
"security_groups": [],
"status": "ACTIVE",
"tags": ["tag1,tag2"],
"tenant_id": "7e02058126cc4950b75f9970368ba177",
"updated_at": "2016-03-08T20:19:41",
"qos_policy_id": "29d5e02e-d5ab-4929-bee4-4a9fc12e22ae",
"resource_request": {
"required": ["CUSTOM_PHYSNET_PUBLIC", "CUSTOM_VNIC_TYPE_NORMAL"],
"resources": {"NET_BW_EGR_KILOBIT_PER_SEC": 1000}
},
"uplink_status_propagation": false
}
}

View File

@ -0,0 +1,32 @@
{
"subnet": {
"name": "private-subnet",
"enable_dhcp": true,
"network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
"segment_id": null,
"project_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
"tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
"dns_nameservers": [],
"dns_publish_fixed_ip": false,
"allocation_pools": [
{
"start": "10.0.0.2",
"end": "10.0.0.254"
}
],
"host_routes": [],
"ip_version": 4,
"gateway_ip": "10.0.0.1",
"cidr": "10.0.0.0/24",
"id": "08eae331-0402-425a-923c-34f7cfe39c1b",
"created_at": "2016-10-10T14:35:34Z",
"description": "",
"ipv6_address_mode": null,
"ipv6_ra_mode": null,
"revision_number": 2,
"service_types": [],
"subnetpool_id": null,
"tags": ["tag1,tag2"],
"updated_at": "2016-10-10T14:35:34Z"
}
}

View File

@ -0,0 +1,32 @@
{
"subnet": {
"name": "private-subnet",
"enable_dhcp": true,
"network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
"segment_id": null,
"project_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
"tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
"dns_nameservers": [],
"dns_publish_fixed_ip": false,
"allocation_pools": [
{
"start": "fd00:203:0:113::2",
"end": "fd00:203:0:113:ffff:ffff:ffff:ffff"
}
],
"host_routes": [],
"ip_version": 6,
"gateway_ip": "fd00:203:0:113::1",
"cidr": "fd00:203:0:113::/64",
"id": "08eae331-0402-425a-923c-34f7cfe39c1b",
"created_at": "2016-10-10T14:35:34Z",
"description": "",
"ipv6_address_mode": "slaac",
"ipv6_ra_mode": null,
"revision_number": 2,
"service_types": [],
"subnetpool_id": null,
"tags": ["tag1,tag2"],
"updated_at": "2016-10-10T14:35:34Z"
}
}

View File

@ -11,6 +11,8 @@
# under the License.
import copy
import json
import os
import time
from unittest import mock
@ -270,6 +272,30 @@ class TestNeutronNetworkActions(db_base.DbTestCase):
patcher.start()
self.addCleanup(patcher.stop)
port_show_file = os.path.join(
os.path.dirname(__file__), 'json_samples',
'neutron_port_show.json')
with open(port_show_file, 'rb') as fl:
self.port_data = json.load(fl)
self.client_mock.show_port.return_value = self.port_data
network_show_file = os.path.join(
os.path.dirname(__file__), 'json_samples',
'neutron_network_show.json')
with open(network_show_file, 'rb') as fl:
self.network_data = json.load(fl)
self.client_mock.show_network.return_value = self.network_data
subnet_show_file = os.path.join(
os.path.dirname(__file__), 'json_samples',
'neutron_subnet_show.json')
with open(subnet_show_file, 'rb') as fl:
self.subnet_data = json.load(fl)
self.client_mock.show_subnet.return_value = self.subnet_data
@mock.patch.object(neutron, 'update_neutron_port', autospec=True)
def _test_add_ports_to_network(self, update_mock, is_client_id,
security_groups=None,
@ -667,6 +693,103 @@ class TestNeutronNetworkActions(db_base.DbTestCase):
self.client_mock.delete_port.assert_called_once_with(
self.neutron_port['id'])
def test__uncidr_ipv4(self):
network, netmask = neutron._uncidr('10.0.0.0/24')
self.assertEqual('10.0.0.0', network)
self.assertEqual('255.255.255.0', netmask)
def test__uncidr_ipv6(self):
network, netmask = neutron._uncidr('::1/64', ipv6=True)
self.assertEqual('::', network)
self.assertEqual('ffff:ffff:ffff:ffff::', netmask)
def test_get_neutron_port_data(self):
network_data = neutron.get_neutron_port_data('port0', 'vif0')
expected_port = {
'id': 'port0',
'type': 'vif',
'ethernet_mac_address': 'fa:16:3e:23:fd:d7',
'vif_id': '46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2',
'mtu': 1500
}
self.assertEqual(expected_port, network_data['links'][0])
expected_network = {
'id': 'a0304c3a-4f08-4c43-88af-d796509c97d2',
'network_id': 'a87cc70a-3e15-4acf-8205-9b711a3531b7',
'type': 'ipv4',
'link': 'port0',
'ip_address': '10.0.0.2',
'netmask': '255.255.255.0',
'routes': [
{'gateway': '10.0.0.1',
'netmask': '0.0.0.0',
'network': '0.0.0.0'}
]
}
self.assertEqual(expected_network, network_data['networks'][0])
def load_ipv6_files(self):
port_show_file = os.path.join(
os.path.dirname(__file__), 'json_samples',
'neutron_port_show_ipv6.json')
with open(port_show_file, 'rb') as fl:
self.port_data = json.load(fl)
self.client_mock.show_port.return_value = self.port_data
network_show_file = os.path.join(
os.path.dirname(__file__), 'json_samples',
'neutron_network_show_ipv6.json')
with open(network_show_file, 'rb') as fl:
self.network_data = json.load(fl)
self.client_mock.show_network.return_value = self.network_data
subnet_show_file = os.path.join(
os.path.dirname(__file__), 'json_samples',
'neutron_subnet_show_ipv6.json')
with open(subnet_show_file, 'rb') as fl:
self.subnet_data = json.load(fl)
self.client_mock.show_subnet.return_value = self.subnet_data
def test_get_neutron_port_data_ipv6(self):
self.load_ipv6_files()
network_data = neutron.get_neutron_port_data('port1', 'vif1')
print(network_data)
expected_port = {
'id': 'port1',
'type': 'vif',
'ethernet_mac_address': '52:54:00:4f:ef:b7',
'vif_id': '96d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb8',
'mtu': 1500
}
self.assertEqual(expected_port, network_data['links'][0])
expected_network = {
'id': '906e685a-b964-4d58-9939-9cf3af197c67',
'network_id': 'a87cc70a-3e15-4acf-8205-9b711a3531b7',
'type': 'ipv6',
'link': 'port1',
'ip_address': 'fd00:203:0:113::2',
'netmask': 'ffff:ffff:ffff:ffff::',
'routes': [
{'gateway': 'fd00:203:0:113::1',
'netmask': '::0',
'network': '::0'}
]
}
self.assertEqual(expected_network, network_data['networks'][0])
def test_get_node_portmap(self):
with task_manager.acquire(self.context, self.node.uuid) as task:
portmap = neutron.get_node_portmap(task)

View File

@ -339,7 +339,10 @@ class TestFlatInterface(db_base.DbTestCase):
self.assertRaises(exception.UnsupportedDriverExtension,
self.interface.validate_inspection, task)
def test_get_node_network_data(self):
@mock.patch.object(neutron, 'get_neutron_port_data', autospec=True)
def test_get_node_network_data(self, mock_gnpd):
mock_gnpd.return_value = {}
with task_manager.acquire(self.context, self.node.id) as task:
network_data = self.interface.get_node_network_data(task)

View File

@ -699,13 +699,15 @@ class NeutronInterfaceTestCase(db_base.DbTestCase):
self.node.save()
self._test_configure_tenant_networks(is_client_id=True)
@mock.patch.object(neutron_common, 'get_neutron_port_data', autospec=True)
@mock.patch.object(neutron_common, 'wait_for_host_agent', autospec=True)
@mock.patch.object(neutron_common, 'update_neutron_port', autospec=True)
@mock.patch.object(neutron_common, 'get_client', autospec=True)
@mock.patch.object(neutron_common, 'get_local_group_information',
autospec=True)
def test_configure_tenant_networks_with_portgroups(
self, glgi_mock, client_mock, update_mock, wait_agent_mock):
self, glgi_mock, client_mock, update_mock, wait_agent_mock,
port_data_mock):
pg = utils.create_test_portgroup(
self.context, node_id=self.node.id, address='ff:54:00:cf:2d:32',
extra={'vif_port_id': uuidutils.generate_uuid()})
@ -860,7 +862,10 @@ class NeutronInterfaceTestCase(db_base.DbTestCase):
self.assertRaises(exception.UnsupportedDriverExtension,
self.interface.validate_inspection, task)
def test_get_node_network_data(self):
@mock.patch.object(neutron_common, 'get_neutron_port_data', autospec=True)
def test_get_node_network_data(self, mock_gnpd):
mock_gnpd.return_value = {}
with task_manager.acquire(self.context, self.node.id) as task:
network_data = self.interface.get_node_network_data(task)

View File

@ -0,0 +1,7 @@
---
features:
- |
Adds `network_data` property to the node, a dictionary that represents the
node static network configuration. The Ironic API performs formal JSON
validation of node `network_data` content against user-supplied JSON schema
at driver validation step.