diff --git a/metalsmith/_network_metadata.py b/metalsmith/_network_metadata.py new file mode 100644 index 0000000..574c70a --- /dev/null +++ b/metalsmith/_network_metadata.py @@ -0,0 +1,93 @@ +# Copyright 2021 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 ipaddress + +from openstack import exceptions as sdk_exc + +from metalsmith import exceptions + + +def create_network_metadata(connection, attached_ports): + network_data = {} + if not attached_ports: + return network_data + + links = network_data.setdefault('links', []) + networks = network_data.setdefault('networks', []) + services = network_data.setdefault('services', []) + + for attached_port in attached_ports: + try: + port = connection.network.get_port(attached_port) + net = connection.network.get_network(port.network_id) + subnets = [connection.network.get_subnet(x['subnet_id']) + for x in port.fixed_ips] + subnets_by_id = {x.id: x for x in subnets} + except sdk_exc.SDKException as exc: + raise exceptions.NetworkResourceNotFound( + 'Cannot find network resource: %s' % exc) + + metadata_add_links(links, port, net) + metadata_add_services(services, subnets) + for idx, fixed_ip in enumerate(port.fixed_ips): + subnet = subnets_by_id[fixed_ip['subnet_id']] + metadata_add_network(networks, idx, fixed_ip, port, net, subnet) + + return network_data + + +def metadata_add_links(links, port, network): + links.append({'id': port.id, + 'type': 'phy', + 'mtu': network.mtu, + 'ethernet_mac_address': port.mac_address}) + + +def metadata_add_services(services, subnets): + for subnet in subnets: + for dns_nameserver in subnet.dns_nameservers: + services.append({'type': 'dns', + 'address': dns_nameserver}) + + +def metadata_add_network(networks, idx, fixed_ip, port, network, subnet): + ip_net = ipaddress.ip_network(subnet.cidr) + + net_data = {'id': network.name + str(idx), + 'network_id': network.id, + 'link': port.id, + 'ip_address': fixed_ip['ip_address'], + 'netmask': str(ip_net.netmask)} + + if subnet.ip_version == 4: + net_data['type'] = 'ipv4_dhcp' if subnet.is_dhcp_enabled else 'ipv4' + elif subnet.ip_version == 6: + net_data['type'] = ('ipv6_{}'.format(subnet.ipv6_address_mode) + if subnet.ipv6_address_mode else 'ipv6') + + net_routes = net_data.setdefault('routes', []) + for route in subnet.host_routes: + ip_net = ipaddress.ip_network(route['destination']) + net_routes.append({'network': str(ip_net.network_address), + 'netmask': str(ip_net.netmask), + 'gateway': route['nexthop']}) + + # Services go in both "network" and toplevel. + # Ref: https://docs.openstack.org/nova/latest/_downloads/9119ca7ac90aa2990e762c08baea3a36/network_data.json # noqa + net_services = net_data.setdefault('services', []) + metadata_add_services(net_services, [subnet]) + + networks.append(net_data) diff --git a/metalsmith/_provisioner.py b/metalsmith/_provisioner.py index dad32aa..7ba0138 100644 --- a/metalsmith/_provisioner.py +++ b/metalsmith/_provisioner.py @@ -19,6 +19,7 @@ from openstack import connection from openstack import exceptions as os_exc from metalsmith import _instance +from metalsmith import _network_metadata from metalsmith import _nics from metalsmith import _scheduler from metalsmith import _utils @@ -427,9 +428,13 @@ class Provisioner(object): node, instance_info=instance_info, extra=extra) self.connection.baremetal.validate_node(node) + network_data = _network_metadata.create_network_metadata( + self.connection, node.extra.get(_ATTACHED_PORTS)) + LOG.debug('Generating a configdrive for node %s', _utils.log_res(node)) - cd = config.generate(node, _utils.hostname_for(node, allocation)) + cd = config.generate(node, _utils.hostname_for(node, allocation), + network_data) LOG.debug('Starting provisioning of node %s', _utils.log_res(node)) self.connection.baremetal.set_node_provision_state( node, 'active', config_drive=cd) diff --git a/metalsmith/exceptions.py b/metalsmith/exceptions.py index f85eeb3..64379fb 100644 --- a/metalsmith/exceptions.py +++ b/metalsmith/exceptions.py @@ -95,6 +95,10 @@ class InstanceNotFound(Error): """Instance not found or node doesn't have an instance associated.""" +class NetworkResourceNotFound(Error): + """Network resource, port, network, subnet not found""" + + # Deprecated aliases DeploymentFailure = DeploymentFailed InvalidInstance = InstanceNotFound diff --git a/metalsmith/instance_config.py b/metalsmith/instance_config.py index 2c5608b..82ddc3b 100644 --- a/metalsmith/instance_config.py +++ b/metalsmith/instance_config.py @@ -46,15 +46,18 @@ class GenericConfig(object): 'got %r' % meta_data) self.meta_data = meta_data or {} - def generate(self, node, hostname=None): + def generate(self, node, hostname=None, network_data=None): """Generate the config drive information. :param node: `Node` object. :param hostname: Desired hostname (defaults to node's name or ID). + :param network_data: Network metadata as dictionary :return: configdrive contents as a dictionary with keys: ``meta_data`` meta data dictionary + ``network_data`` + network data as dictionary ``user_data`` user data as a string """ @@ -81,8 +84,12 @@ class GenericConfig(object): user_data = self.populate_user_data() - return {'meta_data': meta_data, - 'user_data': user_data} + data = {'meta_data': meta_data, 'user_data': user_data} + + if network_data: + data['network_data'] = network_data + + return data def populate_user_data(self): """Get user data for this configuration. diff --git a/metalsmith/test/test_instance_config.py b/metalsmith/test/test_instance_config.py index d4ec99d..43f3777 100644 --- a/metalsmith/test/test_instance_config.py +++ b/metalsmith/test/test_instance_config.py @@ -29,7 +29,8 @@ class TestGenericConfig(unittest.TestCase): self.node.name = 'node name' def _check(self, config, expected_metadata, expected_userdata=None, - cloud_init=True, hostname=None): + cloud_init=True, hostname=None, network_data=None, + expected_network_data=None): expected_m = {'public_keys': {}, 'uuid': self.node.id, 'name': self.node.name, @@ -40,7 +41,7 @@ class TestGenericConfig(unittest.TestCase): 'meta': {}} expected_m.update(expected_metadata) - result = config.generate(self.node, hostname) + result = config.generate(self.node, hostname, network_data) self.assertEqual(expected_m, result['meta_data']) user_data = result['user_data'] @@ -52,6 +53,11 @@ class TestGenericConfig(unittest.TestCase): user_data = json.loads(user_data) self.assertEqual(expected_userdata, user_data) + network_data = result.get('network_data') + if expected_network_data: + self.assertIsNotNone(network_data) + self.assertEqual(expected_network_data, network_data) + def test_default(self): config = self.CLASS() self._check(config, {}) @@ -85,6 +91,11 @@ class TestGenericConfig(unittest.TestCase): def test_custom_metadata_not_dict(self): self.assertRaises(TypeError, self.CLASS, meta_data="foobar") + def test_custom_network_data(self): + config = self.CLASS() + data = {'net': 'data'} + self._check(config, {}, network_data=data, expected_network_data=data) + class TestCloudInitConfig(TestGenericConfig): CLASS = instance_config.CloudInitConfig diff --git a/metalsmith/test/test_network_metadata.py b/metalsmith/test/test_network_metadata.py new file mode 100644 index 0000000..b8ab3a3 --- /dev/null +++ b/metalsmith/test/test_network_metadata.py @@ -0,0 +1,130 @@ +# Copyright 2021 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 unittest +from unittest import mock + +from metalsmith import _network_metadata + + +class TestMetadataAdd(unittest.TestCase): + + def test_metadata_add_links(self): + port = mock.Mock() + network = mock.Mock() + port.id = 'port_id' + port.mac_address = 'aa:bb:cc:dd:ee:ff' + network.mtu = 1500 + links = [] + expected = [{'id': 'port_id', + 'type': 'phy', + 'mtu': 1500, + 'ethernet_mac_address': 'aa:bb:cc:dd:ee:ff'}] + _network_metadata.metadata_add_links(links, port, network) + self.assertEqual(expected, links) + + def test_metadata_add_services(self): + subnet_a = mock.Mock() + subnet_b = mock.Mock() + subnet_a.dns_nameservers = ['192.0.2.1', '192.0.2.2'] + subnet_b.dns_nameservers = ['192.0.2.11', '192.0.2.22'] + subnets = [subnet_a, subnet_b] + services = [] + expected = [{'address': '192.0.2.1', 'type': 'dns'}, + {'address': '192.0.2.2', 'type': 'dns'}, + {'address': '192.0.2.11', 'type': 'dns'}, + {'address': '192.0.2.22', 'type': 'dns'}] + _network_metadata.metadata_add_services(services, subnets) + self.assertEqual(expected, services) + + def test_metadata_add_network_ipv4_dhcp(self): + idx = 1 + fixed_ip = {'ip_address': '192.0.2.100', 'subnet_id': 'subnet_id'} + + port = mock.Mock() + port.id = 'port_id' + + subnet = mock.Mock() + subnet.cidr = '192.0.2.0/26' + subnet.ip_version = 4 + subnet.is_dhcp_enabled = True + subnet.host_routes = [ + {'destination': '192.0.2.64/26', 'nexthop': '192.0.2.1'}, + {'destination': '192.0.2.128/26', 'nexthop': '192.0.2.1'} + ] + subnet.dns_nameservers = ['192.0.2.11', '192.0.2.22'] + + network = mock.Mock() + network.id = 'network_id' + network.name = 'net_name' + + networks = [] + expected = [{'id': 'net_name1', + 'ip_address': '192.0.2.100', + 'link': 'port_id', + 'netmask': '255.255.255.192', + 'network_id': 'network_id', + 'routes': [{'gateway': '192.0.2.1', + 'netmask': '255.255.255.192', + 'network': '192.0.2.64'}, + {'gateway': '192.0.2.1', + 'netmask': '255.255.255.192', + 'network': '192.0.2.128'}], + 'services': [{'address': '192.0.2.11', 'type': 'dns'}, + {'address': '192.0.2.22', 'type': 'dns'}], + 'type': 'ipv4_dhcp'}] + _network_metadata.metadata_add_network(networks, idx, fixed_ip, port, + network, subnet) + self.assertEqual(expected, networks) + + def test_metadata_add_network_ipv6_stateful(self): + idx = 1 + fixed_ip = {'ip_address': '2001:db8:1::10', 'subnet_id': 'subnet_id'} + port = mock.Mock() + port.id = 'port_id' + + subnet = mock.Mock() + subnet.cidr = '2001:db8:1::/64' + subnet.ip_version = 6 + subnet.ipv6_address_mode = 'dhcpv6-stateful' + subnet.host_routes = [ + {'destination': '2001:db8:2::/64', 'nexthop': '2001:db8:1::1'}, + {'destination': '2001:db8:3::/64', 'nexthop': '2001:db8:1::1'} + ] + subnet.dns_nameservers = ['2001:db8:1::ee', '2001:db8:2::ff'] + + network = mock.Mock() + network.id = 'network_id' + network.name = 'net_name' + + networks = [] + expected = [ + {'id': 'net_name1', + 'ip_address': '2001:db8:1::10', + 'link': 'port_id', + 'netmask': 'ffff:ffff:ffff:ffff::', + 'network_id': 'network_id', + 'routes': [{'gateway': '2001:db8:1::1', + 'netmask': 'ffff:ffff:ffff:ffff::', + 'network': '2001:db8:2::'}, + {'gateway': '2001:db8:1::1', + 'netmask': 'ffff:ffff:ffff:ffff::', + 'network': '2001:db8:3::'}], + 'services': [{'address': '2001:db8:1::ee', 'type': 'dns'}, + {'address': '2001:db8:2::ff', 'type': 'dns'}], + 'type': 'ipv6_dhcpv6-stateful'}] + _network_metadata.metadata_add_network(networks, idx, fixed_ip, port, + network, subnet) + self.assertEqual(expected, networks) diff --git a/metalsmith/test/test_provisioner.py b/metalsmith/test/test_provisioner.py index 1254ef9..f66afbb 100644 --- a/metalsmith/test/test_provisioner.py +++ b/metalsmith/test/test_provisioner.py @@ -20,6 +20,7 @@ from openstack import exceptions as os_exc import requests from metalsmith import _instance +from metalsmith import _network_metadata from metalsmith import _provisioner from metalsmith import _utils from metalsmith import exceptions @@ -510,6 +511,12 @@ class TestProvisionNode(Base): self.configdrive_mock = configdrive_patcher.start() self.addCleanup(configdrive_patcher.stop) + create_network_metadata_patches = mock.patch.object( + _network_metadata, 'create_network_metadata', autospec=True + ) + self.network_metadata_mock = create_network_metadata_patches.start() + self.addCleanup(create_network_metadata_patches.stop) + self.api.baremetal.get_node.side_effect = lambda _n: self.node self.api.baremetal.get_allocation.side_effect = ( lambda _a: self.allocation) @@ -531,7 +538,8 @@ class TestProvisionNode(Base): self.node, instance_info=self.instance_info, extra=self.extra) self.api.baremetal.validate_node.assert_called_once_with(self.node) self.configdrive_mock.assert_called_once_with(mock.ANY, self.node, - self.allocation.name) + self.allocation.name, + mock.ANY) self.api.baremetal.set_node_provision_state.assert_called_once_with( self.node, 'active', config_drive=mock.ANY) self.assertFalse(self.api.network.delete_port.called) @@ -553,7 +561,7 @@ class TestProvisionNode(Base): self.node, instance_info=self.instance_info, extra=self.extra) self.api.baremetal.validate_node.assert_called_once_with(self.node) self.configdrive_mock.assert_called_once_with(mock.ANY, self.node, - self.node.name) + self.node.name, mock.ANY) self.api.baremetal.set_node_provision_state.assert_called_once_with( self.node, 'active', config_drive=mock.ANY) self.assertFalse(self.api.network.delete_port.called) @@ -610,7 +618,7 @@ class TestProvisionNode(Base): self.assertEqual(inst.node, self.node) config.generate.assert_called_once_with(self.node, - self.allocation.name) + self.allocation.name, mock.ANY) self.api.network.create_port.assert_called_once_with( network_id=self.api.network.find_network.return_value.id, name='example.com-%s' % @@ -660,7 +668,7 @@ class TestProvisionNode(Base): self.node, instance_info=self.instance_info, extra=self.extra) self.api.baremetal.validate_node.assert_called_once_with(self.node) self.configdrive_mock.assert_called_once_with(mock.ANY, self.node, - hostname) + hostname, mock.ANY) self.api.baremetal.set_node_provision_state.assert_called_once_with( self.node, 'active', config_drive=mock.ANY) self.assertFalse( @@ -688,7 +696,7 @@ class TestProvisionNode(Base): self.node, instance_info=self.instance_info, extra=self.extra) self.api.baremetal.validate_node.assert_called_once_with(self.node) self.configdrive_mock.assert_called_once_with(mock.ANY, self.node, - hostname) + hostname, mock.ANY) self.api.baremetal.set_node_provision_state.assert_called_once_with( self.node, 'active', config_drive=mock.ANY) self.assertFalse( @@ -718,7 +726,7 @@ class TestProvisionNode(Base): self.node, instance_info=self.instance_info, extra=self.extra) self.api.baremetal.validate_node.assert_called_once_with(self.node) self.configdrive_mock.assert_called_once_with(mock.ANY, self.node, - hostname) + hostname, mock.ANY) self.api.baremetal.set_node_provision_state.assert_called_once_with( self.node, 'active', config_drive=mock.ANY) self.assertFalse( @@ -775,7 +783,7 @@ class TestProvisionNode(Base): self.node, extra=self.extra, instance_info=self.instance_info) self.api.baremetal.validate_node.assert_called_once_with(self.node) self.configdrive_mock.assert_called_once_with(mock.ANY, self.node, - self.node.name) + self.node.name, mock.ANY) self.api.baremetal.set_node_provision_state.assert_called_once_with( self.node, 'active', config_drive=mock.ANY) self.assertFalse( @@ -808,7 +816,7 @@ class TestProvisionNode(Base): self.node, extra=self.extra, instance_info=self.instance_info) self.api.baremetal.validate_node.assert_called_once_with(self.node) self.configdrive_mock.assert_called_once_with(mock.ANY, self.node, - self.node.id) + self.node.id, mock.ANY) self.api.baremetal.set_node_provision_state.assert_called_once_with( self.node, 'active', config_drive=mock.ANY) self.assertFalse( diff --git a/releasenotes/notes/network-metadata-ff0c3e80e5e0f53c.yaml b/releasenotes/notes/network-metadata-ff0c3e80e5e0f53c.yaml new file mode 100644 index 0000000..a9a78a7 --- /dev/null +++ b/releasenotes/notes/network-metadata-ff0c3e80e5e0f53c.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + Network metadata is now created and written to the instance config in the + config-drive for deployed nodes. +fixes: + - | + Fixed and issue where deployed nodes did not become available over the + network. This happened when the first network interface was not connected + to a network with a DHCP service, i.e a secondary network interface was + used. The addition of network metadata in the instance config solves this + problem. See bug: + `2009238 `_.